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:
@@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage';
|
|||||||
import { AuthCallback } from './pages/AuthCallback';
|
import { AuthCallback } from './pages/AuthCallback';
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
import { CombatPage } from './pages/CombatPage';
|
import { CombatPage } from './pages/CombatPage';
|
||||||
|
import { TurnCombatPage } from './pages/TurnCombatPage';
|
||||||
import { InventoryPage } from './pages/InventoryPage';
|
import { InventoryPage } from './pages/InventoryPage';
|
||||||
import { CraftPage } from './pages/CraftPage';
|
import { CraftPage } from './pages/CraftPage';
|
||||||
import { ForgePage } from './pages/ForgePage';
|
import { ForgePage } from './pages/ForgePage';
|
||||||
@@ -38,6 +39,7 @@ function AppRoutes() {
|
|||||||
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
|
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
|
||||||
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
|
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
|
||||||
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
|
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
|
||||||
|
<Route path="/combat/tactical" element={<ProtectedLayout><TurnCombatPage /></ProtectedLayout>} />
|
||||||
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
|
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
|
||||||
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
|
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
|
||||||
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
|
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api } from './client';
|
|||||||
import type {
|
import type {
|
||||||
User, Character, Monster, CombatLog,
|
User, Character, Monster, CombatLog,
|
||||||
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
|
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
|
||||||
|
TurnResult, TurnSpell, DaoPathProgress,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
@@ -31,6 +32,23 @@ export const combatApi = {
|
|||||||
history: () => api.get<CombatLog[]>('/combat/history'),
|
history: () => api.get<CombatLog[]>('/combat/history'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Turn Combat
|
||||||
|
export const turnCombatApi = {
|
||||||
|
start: (monsterId: string, attackType: string, companion?: string | null) =>
|
||||||
|
api.post<TurnResult>('/combat/turn/start', { monsterId, attackType, ...(companion ? { companion } : {}) }),
|
||||||
|
action: (sessionId: string, type: string, spellId?: string) =>
|
||||||
|
api.post<TurnResult>('/combat/turn/action', { sessionId, type, ...(spellId ? { spellId } : {}) }),
|
||||||
|
session: (sessionId: string) =>
|
||||||
|
api.get<TurnResult>(`/combat/turn/session/${sessionId}`),
|
||||||
|
spells: () => api.get<TurnSpell[]>('/combat/turn/spells'),
|
||||||
|
unlockedSpells: () => api.get<TurnSpell[]>('/combat/turn/spells/unlocked'),
|
||||||
|
unlockSpell: (spellId: string) =>
|
||||||
|
api.post<any>('/combat/turn/spells/unlock', { spellId }),
|
||||||
|
dao: () => api.get<DaoPathProgress[]>('/combat/turn/dao'),
|
||||||
|
chooseDaoPath: (path: string) =>
|
||||||
|
api.post<DaoPathProgress>('/combat/turn/dao/choose', { path }),
|
||||||
|
};
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
export const itemApi = {
|
export const itemApi = {
|
||||||
catalogue: () => api.get<Item[]>('/items'),
|
catalogue: () => api.get<Item[]>('/items'),
|
||||||
|
|||||||
@@ -113,6 +113,85 @@ export interface CombatLog {
|
|||||||
monster: { id: string; name: string; minLevel: number; maxLevel: number };
|
monster: { id: string; name: string; minLevel: number; maxLevel: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Turn Combat ----------
|
||||||
|
|
||||||
|
export interface TurnBuff {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stat: string;
|
||||||
|
value: number;
|
||||||
|
isPercent: boolean;
|
||||||
|
remainingTurns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnLogEntry {
|
||||||
|
round: number;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
detail: string;
|
||||||
|
hpAfter: { player: number; monster: number; companion?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnResult {
|
||||||
|
sessionId: string;
|
||||||
|
round: number;
|
||||||
|
playerName: string;
|
||||||
|
monsterName: string;
|
||||||
|
events: TurnLogEntry[];
|
||||||
|
playerHp: number;
|
||||||
|
playerHpMax: number;
|
||||||
|
playerMana: number;
|
||||||
|
playerManaMax: number;
|
||||||
|
monsterHp: number;
|
||||||
|
monsterHpMax: number;
|
||||||
|
companion?: {
|
||||||
|
name: string;
|
||||||
|
type: 'mira' | 'vell';
|
||||||
|
hpCurrent: number;
|
||||||
|
hpMax: number;
|
||||||
|
manaCurrent: number;
|
||||||
|
manaMax: number;
|
||||||
|
activeBuffs: TurnBuff[];
|
||||||
|
activeDebuffs: TurnBuff[];
|
||||||
|
} | null;
|
||||||
|
activeBuffs: TurnBuff[];
|
||||||
|
activeDebuffs: TurnBuff[];
|
||||||
|
monsterBuffs: TurnBuff[];
|
||||||
|
monsterDebuffs: TurnBuff[];
|
||||||
|
spellCooldowns: Record<string, number>;
|
||||||
|
bossPhase: number;
|
||||||
|
status: 'awaiting_player' | 'resolving' | 'finished';
|
||||||
|
winner?: 'player' | 'monster';
|
||||||
|
rewards?: {
|
||||||
|
xp: number;
|
||||||
|
gold: number;
|
||||||
|
levelUp: boolean;
|
||||||
|
newLevel: number;
|
||||||
|
statPointsGained: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnSpell {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
pathLevel: number;
|
||||||
|
manaCost: number;
|
||||||
|
cooldown: number;
|
||||||
|
targetType: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaoPathProgress {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
pathPoints: number;
|
||||||
|
pathLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Items & Economy ----------
|
||||||
|
|
||||||
export type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
|
export type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { combatApi, characterApi } from '../api/endpoints';
|
import { combatApi, characterApi } from '../api/endpoints';
|
||||||
import type { Monster, CombatResult, MultiCombatResult } from '../api/types';
|
import type { Monster, CombatResult, MultiCombatResult } from '../api/types';
|
||||||
import { Swords, Clock, Zap, Heart, Lock } from 'lucide-react';
|
import { Swords, Clock, Zap, Heart, Lock, Sparkles } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { COMBAT_COST, REST_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
|
import { COMBAT_COST, REST_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
|
||||||
import { MonsterCard } from '../components/MonsterCard';
|
import { MonsterCard } from '../components/MonsterCard';
|
||||||
import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews';
|
import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews';
|
||||||
@@ -85,7 +86,12 @@ export function CombatPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>⚔️ Combat</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ margin: 0, color: '#f4c94e', fontSize: 20 }}>⚔️ Combat</h2>
|
||||||
|
<Link to="/combat/tactical" className="btn btn-blue" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '0.5rem 1rem' }}>
|
||||||
|
<Sparkles size={14} /> Combat Tactique
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid-2" style={{ marginBottom: '1rem' }}>
|
<div className="grid-2" style={{ marginBottom: '1rem' }}>
|
||||||
{/* Choix monstre par zone */}
|
{/* Choix monstre par zone */}
|
||||||
|
|||||||
420
frontend/src/pages/TurnCombatPage.tsx
Normal file
420
frontend/src/pages/TurnCombatPage.tsx
Normal 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 +{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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@ export class Character {
|
|||||||
@Column({ name: 'hp_max', default: 100 })
|
@Column({ name: 'hp_max', default: 100 })
|
||||||
hpMax: number;
|
hpMax: number;
|
||||||
|
|
||||||
|
// Mana du Courant (sorts — combat tour par tour)
|
||||||
|
@Column({ name: 'mana_current', default: 50 })
|
||||||
|
manaCurrent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'mana_max', default: 50 })
|
||||||
|
manaMax: number;
|
||||||
|
|
||||||
// Endurance — lazy calculation (pas de timer actif)
|
// Endurance — lazy calculation (pas de timer actif)
|
||||||
@Column({ name: 'endurance_saved', default: 100 })
|
@Column({ name: 'endurance_saved', default: 100 })
|
||||||
enduranceSaved: number;
|
enduranceSaved: number;
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { ItemModule } from '../item/item.module';
|
import { ItemModule } from '../item/item.module';
|
||||||
import { MaterialModule } from '../material/material.module';
|
import { MaterialModule } from '../material/material.module';
|
||||||
import { CommunityModule } from '../community/community.module';
|
import { CommunityModule } from '../community/community.module';
|
||||||
|
import { Spell } from './turn/spell.entity';
|
||||||
|
import { PlayerSpell } from './turn/player-spell.entity';
|
||||||
|
import { PlayerDaoPath } from './turn/player-dao-path.entity';
|
||||||
|
import { SpellSystem } from './turn/spell.system';
|
||||||
|
import { TurnCombatService } from './turn/turn-combat.service';
|
||||||
|
import { TurnCombatController } from './turn/turn-combat.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Character, CombatLog]),
|
TypeOrmModule.forFeature([Character, CombatLog, Spell, PlayerSpell, PlayerDaoPath]),
|
||||||
MonsterModule,
|
MonsterModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ItemModule,
|
ItemModule,
|
||||||
MaterialModule,
|
MaterialModule,
|
||||||
CommunityModule,
|
CommunityModule,
|
||||||
],
|
],
|
||||||
controllers: [CombatController],
|
controllers: [CombatController, TurnCombatController],
|
||||||
providers: [CombatService],
|
providers: [CombatService, SpellSystem, TurnCombatService],
|
||||||
})
|
})
|
||||||
export class CombatModule {}
|
export class CombatModule {}
|
||||||
|
|||||||
425
src/combat/turn/companion-ai.ts
Normal file
425
src/combat/turn/companion-ai.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import {
|
||||||
|
CompanionState,
|
||||||
|
CombatSession,
|
||||||
|
TurnLogEntry,
|
||||||
|
Buff,
|
||||||
|
Debuff,
|
||||||
|
} from './types';
|
||||||
|
import { calcMonsterDamage, rollCrit, rollDodge } from '../combat.engine';
|
||||||
|
|
||||||
|
// ---------- Companion Factory ----------
|
||||||
|
|
||||||
|
export type CompanionType = 'mira' | 'vell';
|
||||||
|
|
||||||
|
const MIRA_HP_RATIO = 0.6;
|
||||||
|
const VELL_HP_RATIO = 1.2;
|
||||||
|
|
||||||
|
export function createCompanion(
|
||||||
|
type: CompanionType,
|
||||||
|
playerHpMax: number,
|
||||||
|
playerIntelligence: number,
|
||||||
|
playerForce: number,
|
||||||
|
): CompanionState {
|
||||||
|
if (type === 'mira') {
|
||||||
|
return {
|
||||||
|
name: 'Mira',
|
||||||
|
type: 'mira',
|
||||||
|
hpCurrent: Math.floor(playerHpMax * MIRA_HP_RATIO),
|
||||||
|
hpMax: Math.floor(playerHpMax * MIRA_HP_RATIO),
|
||||||
|
manaCurrent: 40,
|
||||||
|
manaMax: 40,
|
||||||
|
force: Math.floor(playerForce * 0.3),
|
||||||
|
agilite: 5,
|
||||||
|
intelligence: Math.floor(playerIntelligence * 1.2),
|
||||||
|
chance: 3,
|
||||||
|
activeBuffs: [],
|
||||||
|
activeDebuffs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// vell
|
||||||
|
return {
|
||||||
|
name: 'Vell',
|
||||||
|
type: 'vell',
|
||||||
|
hpCurrent: Math.floor(playerHpMax * VELL_HP_RATIO),
|
||||||
|
hpMax: Math.floor(playerHpMax * VELL_HP_RATIO),
|
||||||
|
manaCurrent: 20,
|
||||||
|
manaMax: 20,
|
||||||
|
force: Math.floor(playerForce * 1.3),
|
||||||
|
agilite: 8,
|
||||||
|
intelligence: Math.floor(playerIntelligence * 0.3),
|
||||||
|
chance: 5,
|
||||||
|
activeBuffs: [],
|
||||||
|
activeDebuffs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Companion AI Decision ----------
|
||||||
|
|
||||||
|
export interface CompanionAction {
|
||||||
|
action: string;
|
||||||
|
events: TurnLogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide et execute l'action du compagnon.
|
||||||
|
* Modifie la session directement (HP, buffs, etc.).
|
||||||
|
*/
|
||||||
|
export function resolveCompanionTurn(session: CombatSession): CompanionAction {
|
||||||
|
const companion = session.companion;
|
||||||
|
if (!companion || companion.hpCurrent <= 0) {
|
||||||
|
return { action: 'ko', events: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick buffs/debuffs compagnon
|
||||||
|
companion.activeBuffs = companion.activeBuffs
|
||||||
|
.map((b) => ({ ...b, remainingTurns: b.remainingTurns - 1 }))
|
||||||
|
.filter((b) => b.remainingTurns > 0);
|
||||||
|
companion.activeDebuffs = companion.activeDebuffs
|
||||||
|
.map((d) => ({ ...d, remainingTurns: d.remainingTurns - 1 }))
|
||||||
|
.filter((d) => d.remainingTurns > 0);
|
||||||
|
|
||||||
|
// Mana regen compagnon (+3/tour)
|
||||||
|
companion.manaCurrent = Math.min(companion.manaMax, companion.manaCurrent + 3);
|
||||||
|
|
||||||
|
if (companion.type === 'mira') {
|
||||||
|
return miraAI(session);
|
||||||
|
}
|
||||||
|
return vellAI(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MIRA — Harmoniste (support/heal) ====================
|
||||||
|
// Priorites :
|
||||||
|
// 1. URGENCE — joueur HP < 25% → heal puissant
|
||||||
|
// 2. PURGE — joueur a >= 2 debuffs → onde de serenite
|
||||||
|
// 3. BOSS SPECIAL — boss phase change → dissolution
|
||||||
|
// 4. SOUTIEN — joueur HP < 40% → heal
|
||||||
|
// 5. BUFF — joueur n'a pas de buff defense → buff
|
||||||
|
// 6. ATTAQUE — defaut (rare)
|
||||||
|
|
||||||
|
function miraAI(session: CombatSession): CompanionAction {
|
||||||
|
const c = session.companion!;
|
||||||
|
const events: TurnLogEntry[] = [];
|
||||||
|
const playerHpRatio = session.playerHp / session.playerHpMax;
|
||||||
|
|
||||||
|
const hpAfter = () => ({
|
||||||
|
player: session.playerHp,
|
||||||
|
monster: session.monsterHp,
|
||||||
|
companion: c.hpCurrent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. URGENCE — joueur HP < 25% → heal puissant
|
||||||
|
if (playerHpRatio < 0.25 && c.manaCurrent >= 15) {
|
||||||
|
const heal = Math.floor(c.intelligence * 2) + Math.floor(session.playerHpMax * 0.1);
|
||||||
|
session.playerHp = Math.min(session.playerHpMax, session.playerHp + heal);
|
||||||
|
c.manaCurrent -= 15;
|
||||||
|
|
||||||
|
// Si HP < 15% et mana suffisant → Symphonie (full heal)
|
||||||
|
if (playerHpRatio < 0.15 && c.manaCurrent >= 30) {
|
||||||
|
const fullHeal = session.playerHpMax - session.playerHp;
|
||||||
|
session.playerHp = session.playerHpMax;
|
||||||
|
c.manaCurrent -= 30;
|
||||||
|
// Purge debuffs
|
||||||
|
session.activeDebuffs = [];
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Symphonie Restauratrice',
|
||||||
|
detail: `Mira entonne la Symphonie ! ${session.playerName} est completement soigne (+${fullHeal + heal} HP) et purifie !`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'symphonie', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Chant Apaisant',
|
||||||
|
detail: `Mira chante pour ${session.playerName} — +${heal} HP !`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'heal', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. PURGE — joueur a >= 2 debuffs
|
||||||
|
if (session.activeDebuffs.length >= 2 && c.manaCurrent >= 25) {
|
||||||
|
c.manaCurrent -= 25;
|
||||||
|
// Buff defense + regen
|
||||||
|
const defBuff: Buff = {
|
||||||
|
id: `mira-serenite-${session.round}`,
|
||||||
|
name: 'Onde de Serenite',
|
||||||
|
stat: 'defense',
|
||||||
|
value: 25,
|
||||||
|
isPercent: true,
|
||||||
|
remainingTurns: 3,
|
||||||
|
sourceSpellId: 'mira-serenite',
|
||||||
|
};
|
||||||
|
const regenBuff: Buff = {
|
||||||
|
id: `mira-regen-${session.round}`,
|
||||||
|
name: 'Regen (Mira)',
|
||||||
|
stat: 'regen',
|
||||||
|
value: 5,
|
||||||
|
isPercent: true,
|
||||||
|
remainingTurns: 3,
|
||||||
|
sourceSpellId: 'mira-serenite',
|
||||||
|
};
|
||||||
|
session.activeBuffs.push(defBuff, regenBuff);
|
||||||
|
// Purge 1 debuff
|
||||||
|
if (session.activeDebuffs.length > 0) {
|
||||||
|
session.activeDebuffs.shift();
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Onde de Serenite',
|
||||||
|
detail: `Mira repand une onde de serenite ! Defense +25%, regen active, debuff purifie.`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'serenite', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. BOSS SPECIAL — dissolution des buffs boss
|
||||||
|
if (session.isBoss && session.monsterBuffs.length > 0 && c.manaCurrent >= 20) {
|
||||||
|
c.manaCurrent -= 20;
|
||||||
|
const removed = session.monsterBuffs.length;
|
||||||
|
session.monsterBuffs = [];
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Dissolution',
|
||||||
|
detail: `Mira dissout les protections de ${session.monsterName} ! (${removed} buff${removed > 1 ? 's' : ''} retire${removed > 1 ? 's' : ''})`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'dissolution', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. SOUTIEN — joueur HP < 40%
|
||||||
|
if (playerHpRatio < 0.4 && c.manaCurrent >= 15) {
|
||||||
|
const heal = Math.floor(c.intelligence * 2) + Math.floor(session.playerHpMax * 0.1);
|
||||||
|
session.playerHp = Math.min(session.playerHpMax, session.playerHp + heal);
|
||||||
|
c.manaCurrent -= 15;
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Chant Apaisant',
|
||||||
|
detail: `Mira chante pour ${session.playerName} — +${heal} HP.`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'heal', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. BUFF — joueur sans buff defense actif
|
||||||
|
const hasDefBuff = session.activeBuffs.some((b) => b.stat === 'defense');
|
||||||
|
if (!hasDefBuff && c.manaCurrent >= 25) {
|
||||||
|
c.manaCurrent -= 25;
|
||||||
|
session.activeBuffs.push({
|
||||||
|
id: `mira-serenite-${session.round}`,
|
||||||
|
name: 'Onde de Serenite',
|
||||||
|
stat: 'defense',
|
||||||
|
value: 25,
|
||||||
|
isPercent: true,
|
||||||
|
remainingTurns: 3,
|
||||||
|
sourceSpellId: 'mira-serenite',
|
||||||
|
});
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Onde de Serenite',
|
||||||
|
detail: `Mira renforce la defense de l'equipe ! (+25%, 3 tours)`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'buff', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. ATTAQUE — defaut (Mira attaque rarement)
|
||||||
|
const damage = Math.max(1, Math.floor(c.intelligence * 0.8));
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - damage);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Mira',
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `Mira lance une onde vers ${session.monsterName} — ${damage} degats.`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'attack', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== VELL — Resonant (tank/dps) ====================
|
||||||
|
// Priorites :
|
||||||
|
// 1. PROTECTION — joueur HP < 30% → taunt (Ancre de Pierre)
|
||||||
|
// 2. RIPOSTE — Vell a recu un coup au tour precedent → Contre-Courant
|
||||||
|
// 3. BOSS PHASE — boss phase >= 2 → degats massifs
|
||||||
|
// 4. OUVERTURE — round <= 2 → onde de choc
|
||||||
|
// 5. DPS — defaut → attaque force
|
||||||
|
|
||||||
|
function vellAI(session: CombatSession): CompanionAction {
|
||||||
|
const c = session.companion!;
|
||||||
|
const events: TurnLogEntry[] = [];
|
||||||
|
const playerHpRatio = session.playerHp / session.playerHpMax;
|
||||||
|
|
||||||
|
const hpAfter = () => ({
|
||||||
|
player: session.playerHp,
|
||||||
|
monster: session.monsterHp,
|
||||||
|
companion: c.hpCurrent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. PROTECTION — joueur HP < 30% → taunt
|
||||||
|
if (playerHpRatio < 0.3 && c.manaCurrent >= 10) {
|
||||||
|
const hasTaunt = c.activeBuffs.some((b) => b.stat === 'taunt');
|
||||||
|
if (!hasTaunt) {
|
||||||
|
c.manaCurrent -= 10;
|
||||||
|
c.activeBuffs.push({
|
||||||
|
id: `vell-taunt-${session.round}`,
|
||||||
|
name: 'Ancre de Pierre',
|
||||||
|
stat: 'taunt',
|
||||||
|
value: 1,
|
||||||
|
isPercent: false,
|
||||||
|
remainingTurns: 2,
|
||||||
|
sourceSpellId: 'vell-taunt',
|
||||||
|
});
|
||||||
|
c.activeBuffs.push({
|
||||||
|
id: `vell-def-${session.round}`,
|
||||||
|
name: 'Defense (Vell)',
|
||||||
|
stat: 'damage_reduction',
|
||||||
|
value: 50,
|
||||||
|
isPercent: true,
|
||||||
|
remainingTurns: 2,
|
||||||
|
sourceSpellId: 'vell-taunt',
|
||||||
|
});
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Vell',
|
||||||
|
action: 'Ancre de Pierre',
|
||||||
|
detail: `Vell s'ancre devant ${session.playerName} ! (taunt + def +50%, 2 tours)`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'taunt', events };
|
||||||
|
}
|
||||||
|
// Taunt deja actif → bouclier de flux sur le joueur
|
||||||
|
if (c.manaCurrent >= 10) {
|
||||||
|
c.manaCurrent -= 10;
|
||||||
|
session.activeBuffs.push({
|
||||||
|
id: `vell-bouclier-${session.round}`,
|
||||||
|
name: 'Bouclier de Flux',
|
||||||
|
stat: 'damage_reduction',
|
||||||
|
value: 40,
|
||||||
|
isPercent: true,
|
||||||
|
remainingTurns: 2,
|
||||||
|
sourceSpellId: 'vell-bouclier',
|
||||||
|
});
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Vell',
|
||||||
|
action: 'Bouclier de Flux',
|
||||||
|
detail: `Vell erige un bouclier de flux autour de ${session.playerName} ! (-40% degats, 2 tours)`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'shield', events };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. RIPOSTE — si Vell vient de se faire toucher (taunt actif = il prend les coups)
|
||||||
|
const hasTaunt = c.activeBuffs.some((b) => b.stat === 'taunt');
|
||||||
|
if (hasTaunt && c.manaCurrent >= 5) {
|
||||||
|
c.manaCurrent -= 5;
|
||||||
|
const riposteDmg = Math.floor(c.force * 2);
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - riposteDmg);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Vell',
|
||||||
|
action: 'Contre-Courant',
|
||||||
|
detail: `Vell contre-attaque ${session.monsterName} — ${riposteDmg} degats !`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'riposte', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. BOSS PHASE >= 2 → degats massifs
|
||||||
|
if (session.isBoss && session.bossPhase >= 2 && c.manaCurrent >= 15) {
|
||||||
|
c.manaCurrent -= 15;
|
||||||
|
const bigDmg = Math.floor(c.force * 3.5);
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - bigDmg);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Vell',
|
||||||
|
action: 'Fracture Sismique',
|
||||||
|
detail: `Vell fracture le sol sous ${session.monsterName} — ${bigDmg} degats massifs !`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'fracture', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. OUVERTURE — round <= 2 → onde de choc
|
||||||
|
if (session.round <= 2 && c.manaCurrent >= 8) {
|
||||||
|
c.manaCurrent -= 8;
|
||||||
|
const aoeDmg = Math.floor(c.force * 1.5);
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - aoeDmg);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Vell',
|
||||||
|
action: 'Onde de Choc',
|
||||||
|
detail: `Vell declenche une onde de choc — ${aoeDmg} degats !`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'onde', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. DPS — attaque force
|
||||||
|
const isCrit = rollCrit(c.chance);
|
||||||
|
let damage = Math.max(1, Math.floor(c.force * 1.2));
|
||||||
|
if (isCrit) damage = Math.floor(damage * 1.5);
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - damage);
|
||||||
|
|
||||||
|
const critText = isCrit ? ' (CRITIQUE !)' : '';
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: 'Vell',
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `Vell frappe ${session.monsterName} — ${damage} degats${critText} !`,
|
||||||
|
hpAfter: hpAfter(),
|
||||||
|
});
|
||||||
|
return { action: 'attack', events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Monster targets companion if taunt active ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine si le monstre doit cibler le compagnon (taunt actif).
|
||||||
|
* Si oui, applique les degats au compagnon au lieu du joueur.
|
||||||
|
* Retourne true si le compagnon a absorbe l'attaque.
|
||||||
|
*/
|
||||||
|
export function companionAbsorbAttack(
|
||||||
|
session: CombatSession,
|
||||||
|
rawDamage: number,
|
||||||
|
events: TurnLogEntry[],
|
||||||
|
): boolean {
|
||||||
|
const c = session.companion;
|
||||||
|
if (!c || c.hpCurrent <= 0) return false;
|
||||||
|
|
||||||
|
const hasTaunt = c.activeBuffs.some((b) => b.stat === 'taunt');
|
||||||
|
if (!hasTaunt) return false;
|
||||||
|
|
||||||
|
// Appliquer reduction de degats compagnon
|
||||||
|
let damage = rawDamage;
|
||||||
|
const reduction = c.activeBuffs
|
||||||
|
.filter((b) => b.stat === 'damage_reduction')
|
||||||
|
.reduce((acc, b) => acc + (b.isPercent ? b.value : 0), 0);
|
||||||
|
if (reduction > 0) {
|
||||||
|
damage = Math.floor(damage * (1 - reduction / 100));
|
||||||
|
}
|
||||||
|
damage = Math.max(1, damage);
|
||||||
|
c.hpCurrent = Math.max(0, c.hpCurrent - damage);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `${c.name} intercepte l'attaque ! ${damage} degats absorbes.${c.hpCurrent <= 0 ? ` ${c.name} est KO !` : ''}`,
|
||||||
|
hpAfter: {
|
||||||
|
player: session.playerHp,
|
||||||
|
monster: session.monsterHp,
|
||||||
|
companion: c.hpCurrent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
7
src/combat/turn/dto/choose-dao-path.dto.ts
Normal file
7
src/combat/turn/dto/choose-dao-path.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
import { DaoPath } from '../types';
|
||||||
|
|
||||||
|
export class ChooseDaoPathDto {
|
||||||
|
@IsIn(['ecoute', 'resonance', 'harmonie'])
|
||||||
|
path: DaoPath;
|
||||||
|
}
|
||||||
15
src/combat/turn/dto/start-turn-combat.dto.ts
Normal file
15
src/combat/turn/dto/start-turn-combat.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsUUID, IsIn, IsOptional } from 'class-validator';
|
||||||
|
import { AttackType } from '../../../monster/monster.entity';
|
||||||
|
|
||||||
|
export class StartTurnCombatDto {
|
||||||
|
@IsUUID()
|
||||||
|
monsterId: string;
|
||||||
|
|
||||||
|
@IsIn(['melee', 'ranged', 'magic'])
|
||||||
|
attackType: AttackType;
|
||||||
|
|
||||||
|
/** Compagnon IA optionnel — present si quete narrative */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['mira', 'vell'])
|
||||||
|
companion?: 'mira' | 'vell' | null;
|
||||||
|
}
|
||||||
18
src/combat/turn/dto/turn-action.dto.ts
Normal file
18
src/combat/turn/dto/turn-action.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsUUID, IsIn, IsOptional } from 'class-validator';
|
||||||
|
import { TurnActionType } from '../types';
|
||||||
|
|
||||||
|
export class TurnActionDto {
|
||||||
|
@IsUUID()
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
@IsIn(['attack', 'spell', 'item', 'flee'])
|
||||||
|
type: TurnActionType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
spellId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
itemId?: string;
|
||||||
|
}
|
||||||
6
src/combat/turn/dto/unlock-spell.dto.ts
Normal file
6
src/combat/turn/dto/unlock-spell.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class UnlockSpellDto {
|
||||||
|
@IsUUID()
|
||||||
|
spellId: string;
|
||||||
|
}
|
||||||
36
src/combat/turn/player-dao-path.entity.ts
Normal file
36
src/combat/turn/player-dao-path.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../character/entities/character.entity';
|
||||||
|
import { DaoPath } from './types';
|
||||||
|
|
||||||
|
@Entity('player_dao_paths')
|
||||||
|
@Unique(['characterId', 'path'])
|
||||||
|
export class PlayerDaoPath {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id' })
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character)
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character: Character;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
path: DaoPath;
|
||||||
|
|
||||||
|
@Column({ name: 'is_primary', default: false })
|
||||||
|
isPrimary: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'path_points', default: 0 })
|
||||||
|
pathPoints: number;
|
||||||
|
|
||||||
|
@Column({ name: 'path_level', default: 0 })
|
||||||
|
pathLevel: number;
|
||||||
|
}
|
||||||
35
src/combat/turn/player-spell.entity.ts
Normal file
35
src/combat/turn/player-spell.entity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../../character/entities/character.entity';
|
||||||
|
import { Spell } from './spell.entity';
|
||||||
|
|
||||||
|
@Entity('player_spells')
|
||||||
|
@Unique(['characterId', 'spellId'])
|
||||||
|
export class PlayerSpell {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id' })
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character)
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character: Character;
|
||||||
|
|
||||||
|
@Column({ name: 'spell_id' })
|
||||||
|
spellId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Spell)
|
||||||
|
@JoinColumn({ name: 'spell_id' })
|
||||||
|
spell: Spell;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'unlocked_at' })
|
||||||
|
unlockedAt: Date;
|
||||||
|
}
|
||||||
36
src/combat/turn/spell.entity.ts
Normal file
36
src/combat/turn/spell.entity.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
import { DaoPath, SpellTargetType } from './types';
|
||||||
|
|
||||||
|
@Entity('spells')
|
||||||
|
export class Spell {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
path: DaoPath;
|
||||||
|
|
||||||
|
@Column({ name: 'path_level' })
|
||||||
|
pathLevel: number;
|
||||||
|
|
||||||
|
@Column({ name: 'mana_cost' })
|
||||||
|
manaCost: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
cooldown: number;
|
||||||
|
|
||||||
|
@Column({ name: 'target_type', type: 'varchar', length: 20 })
|
||||||
|
targetType: SpellTargetType;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/** JSON des effets du sort — SpellEffect[] */
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
effects: object;
|
||||||
|
|
||||||
|
@Column({ name: 'unlock_cost', default: 0 })
|
||||||
|
unlockCost: number;
|
||||||
|
}
|
||||||
397
src/combat/turn/spell.system.ts
Normal file
397
src/combat/turn/spell.system.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Spell } from './spell.entity';
|
||||||
|
import { PlayerSpell } from './player-spell.entity';
|
||||||
|
import { PlayerDaoPath } from './player-dao-path.entity';
|
||||||
|
import {
|
||||||
|
DaoPath,
|
||||||
|
SpellEffect,
|
||||||
|
Buff,
|
||||||
|
Debuff,
|
||||||
|
CombatSession,
|
||||||
|
TurnLogEntry,
|
||||||
|
MANA_REGEN_PER_TURN,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// ---------- Resultat d'un cast ----------
|
||||||
|
|
||||||
|
export interface CastResult {
|
||||||
|
success: boolean;
|
||||||
|
manaCost: number;
|
||||||
|
events: TurnLogEntry[];
|
||||||
|
damageDealt: number;
|
||||||
|
healDone: number;
|
||||||
|
buffsApplied: Buff[];
|
||||||
|
debuffsApplied: Debuff[];
|
||||||
|
purgedBuffs: number;
|
||||||
|
purgedDebuffs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SpellSystem {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Spell)
|
||||||
|
private readonly spellRepo: Repository<Spell>,
|
||||||
|
@InjectRepository(PlayerSpell)
|
||||||
|
private readonly playerSpellRepo: Repository<PlayerSpell>,
|
||||||
|
@InjectRepository(PlayerDaoPath)
|
||||||
|
private readonly daoPathRepo: Repository<PlayerDaoPath>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------- Lecture ----------
|
||||||
|
|
||||||
|
/** Sorts debloques par le joueur */
|
||||||
|
async getUnlockedSpells(characterId: string): Promise<Spell[]> {
|
||||||
|
const playerSpells = await this.playerSpellRepo.find({
|
||||||
|
where: { characterId },
|
||||||
|
relations: ['spell'],
|
||||||
|
});
|
||||||
|
return playerSpells.map((ps) => ps.spell);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tous les sorts (pour affichage arbre) */
|
||||||
|
async getAllSpells(): Promise<Spell[]> {
|
||||||
|
return this.spellRepo.find({ order: { path: 'ASC', pathLevel: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Progression du joueur dans les voies */
|
||||||
|
async getDaoPaths(characterId: string): Promise<PlayerDaoPath[]> {
|
||||||
|
return this.daoPathRepo.find({ where: { characterId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Deblocage ----------
|
||||||
|
|
||||||
|
async unlockSpell(characterId: string, spellId: string): Promise<PlayerSpell> {
|
||||||
|
const spell = await this.spellRepo.findOne({ where: { id: spellId } });
|
||||||
|
if (!spell) throw new BadRequestException('Sort introuvable');
|
||||||
|
|
||||||
|
// Verifier que le joueur a la voie et le niveau requis
|
||||||
|
const daoPath = await this.daoPathRepo.findOne({
|
||||||
|
where: { characterId, path: spell.path },
|
||||||
|
});
|
||||||
|
if (!daoPath) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Voie ${spell.path} non initiee. Choisissez votre voie du Dao.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (daoPath.pathPoints < spell.unlockCost) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Points de voie insuffisants (${daoPath.pathPoints}/${spell.unlockCost})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier pas deja debloque
|
||||||
|
const existing = await this.playerSpellRepo.findOne({
|
||||||
|
where: { characterId, spellId },
|
||||||
|
});
|
||||||
|
if (existing) throw new BadRequestException('Sort deja debloque');
|
||||||
|
|
||||||
|
// Verifier que le sort precedent dans la voie est debloque (sauf niv 1)
|
||||||
|
if (spell.pathLevel > 1) {
|
||||||
|
const previousSpell = await this.spellRepo.findOne({
|
||||||
|
where: { path: spell.path, pathLevel: spell.pathLevel - 1 },
|
||||||
|
});
|
||||||
|
if (previousSpell) {
|
||||||
|
const hasPrevious = await this.playerSpellRepo.findOne({
|
||||||
|
where: { characterId, spellId: previousSpell.id },
|
||||||
|
});
|
||||||
|
if (!hasPrevious) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Debloque d'abord ${previousSpell.name} (niveau ${previousSpell.pathLevel})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depenser les points
|
||||||
|
daoPath.pathPoints -= spell.unlockCost;
|
||||||
|
if (spell.pathLevel > daoPath.pathLevel) {
|
||||||
|
daoPath.pathLevel = spell.pathLevel;
|
||||||
|
}
|
||||||
|
await this.daoPathRepo.save(daoPath);
|
||||||
|
|
||||||
|
const playerSpell = this.playerSpellRepo.create({ characterId, spellId });
|
||||||
|
return this.playerSpellRepo.save(playerSpell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Choix de voie ----------
|
||||||
|
|
||||||
|
async choosePrimaryPath(characterId: string, path: DaoPath): Promise<PlayerDaoPath> {
|
||||||
|
// Verifier qu'aucune voie primaire n'existe deja
|
||||||
|
const existing = await this.daoPathRepo.findOne({
|
||||||
|
where: { characterId, isPrimary: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Voie primaire deja choisie : ${existing.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creer les 3 voies, marquer celle choisie comme primaire
|
||||||
|
const paths: PlayerDaoPath[] = [];
|
||||||
|
for (const p of ['ecoute', 'resonance', 'harmonie'] as DaoPath[]) {
|
||||||
|
let daoPath = await this.daoPathRepo.findOne({
|
||||||
|
where: { characterId, path: p },
|
||||||
|
});
|
||||||
|
if (!daoPath) {
|
||||||
|
daoPath = this.daoPathRepo.create({
|
||||||
|
characterId,
|
||||||
|
path: p,
|
||||||
|
isPrimary: p === path,
|
||||||
|
pathPoints: p === path ? 1 : 0, // premier point gratuit sur la voie principale
|
||||||
|
pathLevel: 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
daoPath.isPrimary = p === path;
|
||||||
|
}
|
||||||
|
paths.push(await this.daoPathRepo.save(daoPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debloquer automatiquement le sort de niveau 1 de la voie choisie
|
||||||
|
const starterSpell = await this.spellRepo.findOne({
|
||||||
|
where: { path, pathLevel: 1 },
|
||||||
|
});
|
||||||
|
if (starterSpell) {
|
||||||
|
const alreadyUnlocked = await this.playerSpellRepo.findOne({
|
||||||
|
where: { characterId, spellId: starterSpell.id },
|
||||||
|
});
|
||||||
|
if (!alreadyUnlocked) {
|
||||||
|
await this.playerSpellRepo.save(
|
||||||
|
this.playerSpellRepo.create({ characterId, spellId: starterSpell.id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths.find((p) => p.isPrimary)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cast en combat ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout un sort pendant le combat tour par tour.
|
||||||
|
* Ne modifie PAS la session directement — retourne les effets a appliquer.
|
||||||
|
*/
|
||||||
|
async cast(
|
||||||
|
session: CombatSession,
|
||||||
|
spellId: string,
|
||||||
|
casterStats: { intelligence: number; force: number; hpMax: number },
|
||||||
|
): Promise<CastResult> {
|
||||||
|
// Verifier que le sort est debloque
|
||||||
|
const playerSpell = await this.playerSpellRepo.findOne({
|
||||||
|
where: { characterId: session.characterId, spellId },
|
||||||
|
relations: ['spell'],
|
||||||
|
});
|
||||||
|
if (!playerSpell) {
|
||||||
|
throw new BadRequestException('Sort non debloque');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spell = playerSpell.spell;
|
||||||
|
|
||||||
|
// Verifier mana
|
||||||
|
if (session.playerMana < spell.manaCost) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Mana insuffisant (${session.playerMana}/${spell.manaCost})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier cooldown
|
||||||
|
const cd = session.spellCooldowns[spellId] ?? 0;
|
||||||
|
if (cd > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Sort en cooldown (${cd} tour${cd > 1 ? 's' : ''} restant${cd > 1 ? 's' : ''})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effects = spell.effects as SpellEffect[];
|
||||||
|
const events: TurnLogEntry[] = [];
|
||||||
|
let totalDamage = 0;
|
||||||
|
let totalHeal = 0;
|
||||||
|
const buffsApplied: Buff[] = [];
|
||||||
|
const debuffsApplied: Debuff[] = [];
|
||||||
|
let purgedBuffs = 0;
|
||||||
|
let purgedDebuffs = 0;
|
||||||
|
|
||||||
|
for (const effect of effects) {
|
||||||
|
switch (effect.type) {
|
||||||
|
case 'damage': {
|
||||||
|
const stat = effect.ratioStat === 'force'
|
||||||
|
? casterStats.force
|
||||||
|
: casterStats.intelligence;
|
||||||
|
const damage = Math.floor(stat * (effect.ratio ?? 1));
|
||||||
|
totalDamage += damage;
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: spell.name,
|
||||||
|
detail: effect.log
|
||||||
|
.replace('{caster}', session.playerName)
|
||||||
|
.replace('{target}', session.monsterName)
|
||||||
|
.replace('{damage}', String(damage)),
|
||||||
|
hpAfter: {
|
||||||
|
player: session.playerHp,
|
||||||
|
monster: Math.max(0, session.monsterHp - damage),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'heal': {
|
||||||
|
// value = % hpMax, ratio = multiplicateur d'int
|
||||||
|
const fromRatio = Math.floor(
|
||||||
|
casterStats.intelligence * (effect.ratio ?? 0),
|
||||||
|
);
|
||||||
|
const fromPercent = Math.floor(
|
||||||
|
casterStats.hpMax * ((effect.value ?? 0) / 100),
|
||||||
|
);
|
||||||
|
const heal = fromRatio + fromPercent;
|
||||||
|
totalHeal += heal;
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: spell.name,
|
||||||
|
detail: effect.log
|
||||||
|
.replace('{caster}', session.playerName)
|
||||||
|
.replace('{target}', session.playerName)
|
||||||
|
.replace('{heal}', String(heal)),
|
||||||
|
hpAfter: {
|
||||||
|
player: Math.min(session.playerHpMax, session.playerHp + heal),
|
||||||
|
monster: session.monsterHp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'buff': {
|
||||||
|
const buff: Buff = {
|
||||||
|
id: `${spell.id}-${effect.stat}-${session.round}`,
|
||||||
|
name: spell.name,
|
||||||
|
stat: effect.stat!,
|
||||||
|
value: effect.value ?? 0,
|
||||||
|
isPercent: effect.isPercent ?? true,
|
||||||
|
remainingTurns: effect.duration ?? 1,
|
||||||
|
sourceSpellId: spell.id,
|
||||||
|
};
|
||||||
|
buffsApplied.push(buff);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: spell.name,
|
||||||
|
detail: effect.log
|
||||||
|
.replace('{caster}', session.playerName)
|
||||||
|
.replace('{target}', session.playerName),
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'debuff': {
|
||||||
|
const debuff: Debuff = {
|
||||||
|
id: `${spell.id}-${effect.stat}-${session.round}`,
|
||||||
|
name: spell.name,
|
||||||
|
stat: effect.stat!,
|
||||||
|
value: effect.value ?? 0,
|
||||||
|
isPercent: effect.isPercent ?? true,
|
||||||
|
remainingTurns: effect.duration ?? 1,
|
||||||
|
sourceSpellId: spell.id,
|
||||||
|
};
|
||||||
|
debuffsApplied.push(debuff);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: spell.name,
|
||||||
|
detail: effect.log
|
||||||
|
.replace('{caster}', session.playerName)
|
||||||
|
.replace('{target}', session.monsterName),
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'purge': {
|
||||||
|
if (effect.stat === 'buff') {
|
||||||
|
// Purge buffs ennemis
|
||||||
|
purgedBuffs += effect.value ?? 1;
|
||||||
|
} else {
|
||||||
|
// Purge debuffs allies
|
||||||
|
purgedDebuffs += effect.value ?? 1;
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: spell.name,
|
||||||
|
detail: effect.log
|
||||||
|
.replace('{caster}', session.playerName)
|
||||||
|
.replace('{target}', session.monsterName),
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'special': {
|
||||||
|
// Les effets speciaux sont resolus par le TurnManager (Phase C)
|
||||||
|
// Ici on les signale dans le log
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: spell.name,
|
||||||
|
detail: effect.log
|
||||||
|
.replace('{caster}', session.playerName)
|
||||||
|
.replace('{target}', session.monsterName),
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
manaCost: spell.manaCost,
|
||||||
|
events,
|
||||||
|
damageDealt: totalDamage,
|
||||||
|
healDone: totalHeal,
|
||||||
|
buffsApplied,
|
||||||
|
debuffsApplied,
|
||||||
|
purgedBuffs,
|
||||||
|
purgedDebuffs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Utilitaires combat ----------
|
||||||
|
|
||||||
|
/** Regen mana en debut de tour */
|
||||||
|
regenMana(currentMana: number, maxMana: number): number {
|
||||||
|
return Math.min(maxMana, currentMana + MANA_REGEN_PER_TURN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tick buffs/debuffs en fin de tour — decremente et retire les expires */
|
||||||
|
tickBuffs(buffs: Buff[]): Buff[] {
|
||||||
|
return buffs
|
||||||
|
.map((b) => ({ ...b, remainingTurns: b.remainingTurns - 1 }))
|
||||||
|
.filter((b) => b.remainingTurns > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
tickDebuffs(debuffs: Debuff[]): Debuff[] {
|
||||||
|
return debuffs
|
||||||
|
.map((d) => ({ ...d, remainingTurns: d.remainingTurns - 1 }))
|
||||||
|
.filter((d) => d.remainingTurns > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calcule le modificateur total d'un stat depuis les buffs actifs */
|
||||||
|
getBuffModifier(buffs: Buff[], stat: string): number {
|
||||||
|
return buffs
|
||||||
|
.filter((b) => b.stat === stat)
|
||||||
|
.reduce((acc, b) => acc + (b.isPercent ? b.value : 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifie si un debuff specifique est actif */
|
||||||
|
hasDebuff(debuffs: Debuff[], stat: string): boolean {
|
||||||
|
return debuffs.some((d) => d.stat === stat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calcule la mana max d'un personnage */
|
||||||
|
computeMaxMana(intelligence: number): number {
|
||||||
|
return 50 + intelligence * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/combat/turn/turn-combat.controller.ts
Normal file
117
src/combat/turn/turn-combat.controller.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { TurnCombatService } from './turn-combat.service';
|
||||||
|
import { SpellSystem } from './spell.system';
|
||||||
|
import { StartTurnCombatDto } from './dto/start-turn-combat.dto';
|
||||||
|
import { TurnActionDto } from './dto/turn-action.dto';
|
||||||
|
import { ChooseDaoPathDto } from './dto/choose-dao-path.dto';
|
||||||
|
import { UnlockSpellDto } from './dto/unlock-spell.dto';
|
||||||
|
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Character } from '../../character/entities/character.entity';
|
||||||
|
|
||||||
|
@Controller('combat/turn')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class TurnCombatController {
|
||||||
|
constructor(
|
||||||
|
private readonly turnCombatService: TurnCombatService,
|
||||||
|
private readonly spellSystem: SpellSystem,
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepo: Repository<Character>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ---------- Combat tour par tour ----------
|
||||||
|
|
||||||
|
@Post('start')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
startCombat(
|
||||||
|
@Body() dto: StartTurnCombatDto,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
return this.turnCombatService.startSession(dto, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('action')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
submitAction(
|
||||||
|
@Body() dto: TurnActionDto,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
return this.turnCombatService.submitAction(
|
||||||
|
dto.sessionId,
|
||||||
|
{ type: dto.type, spellId: dto.spellId, itemId: dto.itemId },
|
||||||
|
req.user.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('session/:sessionId')
|
||||||
|
getSession(
|
||||||
|
@Param('sessionId') sessionId: string,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
return this.turnCombatService.getSession(sessionId, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Dao & Sorts ----------
|
||||||
|
|
||||||
|
@Get('spells')
|
||||||
|
getAllSpells() {
|
||||||
|
return this.spellSystem.getAllSpells();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('spells/unlocked')
|
||||||
|
async getUnlockedSpells(@Req() req: Request & { user: User }) {
|
||||||
|
const character = await this.getCharacter(req.user.id);
|
||||||
|
return this.spellSystem.getUnlockedSpells(character.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('spells/unlock')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async unlockSpell(
|
||||||
|
@Body() dto: UnlockSpellDto,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
const character = await this.getCharacter(req.user.id);
|
||||||
|
return this.spellSystem.unlockSpell(character.id, dto.spellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('dao')
|
||||||
|
async getDaoPaths(@Req() req: Request & { user: User }) {
|
||||||
|
const character = await this.getCharacter(req.user.id);
|
||||||
|
return this.spellSystem.getDaoPaths(character.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('dao/choose')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async chooseDaoPath(
|
||||||
|
@Body() dto: ChooseDaoPathDto,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
const character = await this.getCharacter(req.user.id);
|
||||||
|
return this.spellSystem.choosePrimaryPath(character.id, dto.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helper ----------
|
||||||
|
|
||||||
|
private async getCharacter(userId: string): Promise<Character> {
|
||||||
|
const character = await this.characterRepo.findOne({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
if (!character) {
|
||||||
|
throw new Error('Aucun personnage trouve');
|
||||||
|
}
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
}
|
||||||
935
src/combat/turn/turn-combat.service.ts
Normal file
935
src/combat/turn/turn-combat.service.ts
Normal file
@@ -0,0 +1,935 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Character } from '../../character/entities/character.entity';
|
||||||
|
import { MonsterService } from '../../monster/monster.service';
|
||||||
|
import { ItemService } from '../../item/item.service';
|
||||||
|
import { SpellSystem } from './spell.system';
|
||||||
|
import {
|
||||||
|
CombatSession,
|
||||||
|
TurnAction,
|
||||||
|
TurnResult,
|
||||||
|
TurnLogEntry,
|
||||||
|
MonsterAiProfile,
|
||||||
|
MANA_REGEN_PER_TURN,
|
||||||
|
FLEE_BASE_CHANCE,
|
||||||
|
FLEE_AGILITY_BONUS,
|
||||||
|
SESSION_TTL_MS,
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
calcPlayerDamage,
|
||||||
|
calcMonsterDamage,
|
||||||
|
rollCrit,
|
||||||
|
rollDodge,
|
||||||
|
applyXpGain,
|
||||||
|
xpRequiredForLevel,
|
||||||
|
CombatantStats,
|
||||||
|
} from '../combat.engine';
|
||||||
|
import { CombatLog } from '../combat-log.entity';
|
||||||
|
import { StartTurnCombatDto } from './dto/start-turn-combat.dto';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { createCompanion, resolveCompanionTurn, companionAbsorbAttack } from './companion-ai';
|
||||||
|
|
||||||
|
const MAX_ROUNDS = 30;
|
||||||
|
const COMBAT_ENDURANCE_COST = 5;
|
||||||
|
const VICTORY_HP_REGEN_RATIO = 0.1;
|
||||||
|
const DEFEAT_ENDURANCE_PENALTY = 25;
|
||||||
|
const DEFEAT_HP_RATIO = 0.2;
|
||||||
|
const DEFEAT_GOLD_LOSS_RATIO = 0.05;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TurnCombatService {
|
||||||
|
private readonly sessions = new Map<string, CombatSession>();
|
||||||
|
private cleanupTimer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepo: Repository<Character>,
|
||||||
|
@InjectRepository(CombatLog)
|
||||||
|
private readonly combatLogRepo: Repository<CombatLog>,
|
||||||
|
private readonly monsterService: MonsterService,
|
||||||
|
private readonly itemService: ItemService,
|
||||||
|
private readonly spellSystem: SpellSystem,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
this.cleanupTimer = setInterval(() => this.cleanupExpired(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
clearInterval(this.cleanupTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== START ==========
|
||||||
|
|
||||||
|
async startSession(dto: StartTurnCombatDto, user: User): Promise<TurnResult> {
|
||||||
|
const character = await this.characterRepo.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
if (!character) throw new BadRequestException('Aucun personnage trouve');
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||||
|
const recharge = Math.floor(elapsed / 3);
|
||||||
|
const enduranceCurrent = Math.min(
|
||||||
|
character.enduranceSaved + recharge,
|
||||||
|
character.enduranceMax,
|
||||||
|
);
|
||||||
|
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (character.hpCurrent <= 0) {
|
||||||
|
throw new BadRequestException('Personnage KO — recuperez vos PV');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session active?
|
||||||
|
for (const [, sess] of this.sessions) {
|
||||||
|
if (sess.playerId === user.id && sess.status !== 'finished') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Combat en cours — terminez-le avant d\'en commencer un nouveau',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monster = await this.monsterService.findOne(dto.monsterId);
|
||||||
|
|
||||||
|
// Equipement
|
||||||
|
const equipped = await this.itemService.getEquippedItems(character.id);
|
||||||
|
const FB = 2;
|
||||||
|
const weaponAttack = equipped.weapon
|
||||||
|
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FB
|
||||||
|
: 0;
|
||||||
|
const armorDefense = equipped.armor
|
||||||
|
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FB
|
||||||
|
: 0;
|
||||||
|
const iF = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0);
|
||||||
|
const iA = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0);
|
||||||
|
const iI = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0);
|
||||||
|
const iC = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0);
|
||||||
|
|
||||||
|
const pInt = character.intelligence + iI;
|
||||||
|
const manaMax = this.spellSystem.computeMaxMana(pInt);
|
||||||
|
|
||||||
|
// Debiter endurance immediatement
|
||||||
|
character.enduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST;
|
||||||
|
character.lastEnduranceTs = new Date();
|
||||||
|
await this.characterRepo.save(character);
|
||||||
|
|
||||||
|
const session: CombatSession = {
|
||||||
|
id: uuidv4(),
|
||||||
|
playerId: user.id,
|
||||||
|
characterId: character.id,
|
||||||
|
playerName: character.name,
|
||||||
|
playerHp: character.hpCurrent,
|
||||||
|
playerHpMax: character.hpMax,
|
||||||
|
playerMana: Math.min(character.manaCurrent, manaMax),
|
||||||
|
playerManaMax: manaMax,
|
||||||
|
playerForce: character.force + iF,
|
||||||
|
playerAgilite: character.agilite + iA,
|
||||||
|
playerIntelligence: pInt,
|
||||||
|
playerChance: character.chance + iC,
|
||||||
|
playerAttack: weaponAttack,
|
||||||
|
playerDefense: armorDefense,
|
||||||
|
attackType: dto.attackType,
|
||||||
|
monsterName: monster.name,
|
||||||
|
monsterId: monster.id,
|
||||||
|
monsterHp: monster.hp,
|
||||||
|
monsterHpMax: monster.hp,
|
||||||
|
monsterAttack: monster.attack,
|
||||||
|
monsterDefense: monster.defense,
|
||||||
|
monsterAiProfile: (monster.aiProfile ?? 'aggressive') as MonsterAiProfile,
|
||||||
|
monsterGuardActive: false,
|
||||||
|
monsterLastAction: 'none',
|
||||||
|
isBoss: monster.isBoss ?? false,
|
||||||
|
bossPhase: 1,
|
||||||
|
xpReward: monster.xpReward,
|
||||||
|
goldMin: monster.goldMin,
|
||||||
|
goldMax: monster.goldMax,
|
||||||
|
companion: dto.companion
|
||||||
|
? createCompanion(
|
||||||
|
dto.companion,
|
||||||
|
character.hpMax,
|
||||||
|
pInt,
|
||||||
|
character.force + iF,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
activeBuffs: [],
|
||||||
|
activeDebuffs: [],
|
||||||
|
monsterBuffs: [],
|
||||||
|
monsterDebuffs: [],
|
||||||
|
spellCooldowns: {},
|
||||||
|
round: 1,
|
||||||
|
log: [],
|
||||||
|
status: 'awaiting_player',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
return this.buildTurnResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ACTION ==========
|
||||||
|
|
||||||
|
async submitAction(
|
||||||
|
sessionId: string,
|
||||||
|
action: TurnAction,
|
||||||
|
userId: string,
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) throw new BadRequestException('Session introuvable ou expiree');
|
||||||
|
if (session.playerId !== userId) throw new BadRequestException('Session invalide');
|
||||||
|
if (session.status !== 'awaiting_player') {
|
||||||
|
throw new BadRequestException('Pas votre tour');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.status = 'resolving';
|
||||||
|
|
||||||
|
// Regen mana debut de tour
|
||||||
|
session.playerMana = this.spellSystem.regenMana(
|
||||||
|
session.playerMana,
|
||||||
|
session.playerManaMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: TurnLogEntry[] = [];
|
||||||
|
|
||||||
|
// --- INITIATIVE ---
|
||||||
|
// Joueur plus rapide si agilite >= monstre attack/2 (approximation simple)
|
||||||
|
// En pratique: joueur joue d'abord sauf si monstre est chaotique et roll < 30%
|
||||||
|
const playerFirst = this.resolveInitiative(session);
|
||||||
|
|
||||||
|
if (playerFirst) {
|
||||||
|
// Joueur → Compagnon → Monstre
|
||||||
|
const fled = await this.resolvePlayerAction(session, action, events);
|
||||||
|
if (fled) {
|
||||||
|
session.log.push(...events);
|
||||||
|
return this.buildTurnResult(session);
|
||||||
|
}
|
||||||
|
if (session.monsterHp <= 0) {
|
||||||
|
return this.finishCombat(session, events, 'player');
|
||||||
|
}
|
||||||
|
// Tour compagnon
|
||||||
|
this.doCompanionTurn(session, events);
|
||||||
|
if (session.monsterHp <= 0) {
|
||||||
|
return this.finishCombat(session, events, 'player');
|
||||||
|
}
|
||||||
|
// Tour monstre
|
||||||
|
this.resolveMonsterTurn(session, events);
|
||||||
|
if (session.playerHp <= 0) {
|
||||||
|
return this.finishCombat(session, events, 'monster');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Monstre → Joueur → Compagnon
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Initiative',
|
||||||
|
detail: `${session.monsterName} est plus rapide !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
this.resolveMonsterTurn(session, events);
|
||||||
|
if (session.playerHp <= 0) {
|
||||||
|
return this.finishCombat(session, events, 'monster');
|
||||||
|
}
|
||||||
|
const fled2 = await this.resolvePlayerAction(session, action, events);
|
||||||
|
if (fled2) {
|
||||||
|
session.log.push(...events);
|
||||||
|
return this.buildTurnResult(session);
|
||||||
|
}
|
||||||
|
if (session.monsterHp <= 0) {
|
||||||
|
return this.finishCombat(session, events, 'player');
|
||||||
|
}
|
||||||
|
// Tour compagnon
|
||||||
|
this.doCompanionTurn(session, events);
|
||||||
|
if (session.monsterHp <= 0) {
|
||||||
|
return this.finishCombat(session, events, 'player');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fin de tour: tick buffs/debuffs
|
||||||
|
session.activeBuffs = this.spellSystem.tickBuffs(session.activeBuffs);
|
||||||
|
session.activeDebuffs = this.spellSystem.tickDebuffs(session.activeDebuffs);
|
||||||
|
session.monsterBuffs = this.spellSystem.tickBuffs(session.monsterBuffs);
|
||||||
|
session.monsterDebuffs = this.spellSystem.tickDebuffs(session.monsterDebuffs);
|
||||||
|
|
||||||
|
// Tick cooldowns
|
||||||
|
for (const spellId of Object.keys(session.spellCooldowns)) {
|
||||||
|
session.spellCooldowns[spellId]--;
|
||||||
|
if (session.spellCooldowns[spellId] <= 0) {
|
||||||
|
delete session.spellCooldowns[spellId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick regen buffs
|
||||||
|
this.tickRegenBuffs(session, events);
|
||||||
|
|
||||||
|
// Round max
|
||||||
|
session.round++;
|
||||||
|
if (session.round > MAX_ROUNDS) {
|
||||||
|
return this.finishCombat(session, events, 'monster');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.status = 'awaiting_player';
|
||||||
|
session.log.push(...events);
|
||||||
|
return this.buildTurnResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== COMPANION TURN ==========
|
||||||
|
|
||||||
|
private doCompanionTurn(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
if (!session.companion || session.companion.hpCurrent <= 0) return;
|
||||||
|
const result = resolveCompanionTurn(session);
|
||||||
|
events.push(...result.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== INITIATIVE ==========
|
||||||
|
|
||||||
|
private resolveInitiative(session: CombatSession): boolean {
|
||||||
|
// Joueur joue en premier par defaut (avantage narratif)
|
||||||
|
// Chaotique: 30% chance de voler l'initiative
|
||||||
|
if (session.monsterAiProfile === 'chaotic' && Math.random() < 0.3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Agressif: 15% chance si monstre attack > playerDefense * 2
|
||||||
|
if (
|
||||||
|
session.monsterAiProfile === 'aggressive' &&
|
||||||
|
session.monsterAttack > session.playerDefense * 2 &&
|
||||||
|
Math.random() < 0.15
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PLAYER ACTION ==========
|
||||||
|
|
||||||
|
/** Returns true if player fled */
|
||||||
|
private async resolvePlayerAction(
|
||||||
|
session: CombatSession,
|
||||||
|
action: TurnAction,
|
||||||
|
events: TurnLogEntry[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'attack':
|
||||||
|
this.resolvePlayerAttack(session, events);
|
||||||
|
return false;
|
||||||
|
case 'spell':
|
||||||
|
await this.resolvePlayerSpell(session, action.spellId!, events);
|
||||||
|
return false;
|
||||||
|
case 'flee':
|
||||||
|
if (this.resolvePlayerFlee(session, events)) {
|
||||||
|
session.status = 'finished';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
case 'item':
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Item',
|
||||||
|
detail: `${session.playerName} fouille son sac... (items bientot disponibles)`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PLAYER ATTACK ==========
|
||||||
|
|
||||||
|
private resolvePlayerAttack(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
const stats = this.buildPlayerStats(session);
|
||||||
|
const baseDamage = calcPlayerDamage(stats, session.monsterDefense);
|
||||||
|
const isCrit = rollCrit(session.playerChance);
|
||||||
|
|
||||||
|
let damage = isCrit ? Math.floor(baseDamage * 1.5) : baseDamage;
|
||||||
|
|
||||||
|
// Buff damage
|
||||||
|
const dmgBuff = this.spellSystem.getBuffModifier(session.activeBuffs, 'damage');
|
||||||
|
if (dmgBuff > 0) damage = Math.floor(damage * (1 + dmgBuff / 100));
|
||||||
|
|
||||||
|
// Monster damage reduction (buffs)
|
||||||
|
const monsterReduction = this.spellSystem.getBuffModifier(
|
||||||
|
session.monsterBuffs,
|
||||||
|
'damage_reduction',
|
||||||
|
);
|
||||||
|
if (monsterReduction > 0) {
|
||||||
|
damage = Math.floor(damage * (1 - monsterReduction / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monster guard (defensive AI)
|
||||||
|
if (session.monsterGuardActive) {
|
||||||
|
damage = Math.floor(damage * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
damage = Math.max(1, damage);
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - damage);
|
||||||
|
|
||||||
|
const critText = isCrit ? ' (CRITIQUE !)' : '';
|
||||||
|
const guardText = session.monsterGuardActive ? ' [garde]' : '';
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `${session.playerName} attaque ${session.monsterName} pour ${damage} degats${critText}${guardText}`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PLAYER SPELL ==========
|
||||||
|
|
||||||
|
private async resolvePlayerSpell(
|
||||||
|
session: CombatSession,
|
||||||
|
spellId: string,
|
||||||
|
events: TurnLogEntry[],
|
||||||
|
) {
|
||||||
|
const result = await this.spellSystem.cast(session, spellId, {
|
||||||
|
intelligence: session.playerIntelligence,
|
||||||
|
force: session.playerForce,
|
||||||
|
hpMax: session.playerHpMax,
|
||||||
|
});
|
||||||
|
|
||||||
|
session.playerMana -= result.manaCost;
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - result.damageDealt);
|
||||||
|
session.playerHp = Math.min(
|
||||||
|
session.playerHpMax,
|
||||||
|
session.playerHp + result.healDone,
|
||||||
|
);
|
||||||
|
|
||||||
|
session.activeBuffs.push(...result.buffsApplied);
|
||||||
|
session.monsterDebuffs.push(...result.debuffsApplied);
|
||||||
|
|
||||||
|
if (result.purgedBuffs > 0) {
|
||||||
|
session.monsterBuffs = session.monsterBuffs.slice(result.purgedBuffs);
|
||||||
|
}
|
||||||
|
if (result.purgedDebuffs > 0) {
|
||||||
|
session.activeDebuffs = session.activeDebuffs.slice(result.purgedDebuffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spell = (
|
||||||
|
await this.spellSystem.getUnlockedSpells(session.characterId)
|
||||||
|
).find((s) => s.id === spellId);
|
||||||
|
if (spell) {
|
||||||
|
session.spellCooldowns[spellId] = spell.cooldown;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(...result.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PLAYER FLEE ==========
|
||||||
|
|
||||||
|
private resolvePlayerFlee(
|
||||||
|
session: CombatSession,
|
||||||
|
events: TurnLogEntry[],
|
||||||
|
): boolean {
|
||||||
|
if (session.isBoss) {
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Fuite',
|
||||||
|
detail: `${session.playerName} tente de fuir... impossible face a un boss !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chance = FLEE_BASE_CHANCE + session.playerAgilite * FLEE_AGILITY_BONUS;
|
||||||
|
const success = Math.random() < chance;
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Fuite',
|
||||||
|
detail: success
|
||||||
|
? `${session.playerName} prend la fuite !`
|
||||||
|
: `${session.playerName} tente de fuir... echec !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== MONSTER TURN (AI Profiles) ==========
|
||||||
|
|
||||||
|
private resolveMonsterTurn(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
// Stun
|
||||||
|
if (this.spellSystem.hasDebuff(session.monsterDebuffs, 'stun')) {
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Etourdi',
|
||||||
|
detail: `${session.monsterName} est etourdi et ne peut pas agir !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
session.monsterLastAction = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confusion
|
||||||
|
const isConfused = this.spellSystem.hasDebuff(session.monsterDebuffs, 'precision');
|
||||||
|
if (isConfused && Math.random() < 0.3) {
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Confusion',
|
||||||
|
detail: `${session.monsterName} est confus et rate son attaque !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
session.monsterLastAction = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI dispatch
|
||||||
|
switch (session.monsterAiProfile) {
|
||||||
|
case 'defensive':
|
||||||
|
this.monsterAiDefensive(session, events);
|
||||||
|
break;
|
||||||
|
case 'chaotic':
|
||||||
|
this.monsterAiChaotic(session, events);
|
||||||
|
break;
|
||||||
|
case 'aggressive':
|
||||||
|
case 'boss':
|
||||||
|
default:
|
||||||
|
this.monsterAiAggressive(session, events);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Aggressive: toujours attaque, rage si HP bas ---
|
||||||
|
|
||||||
|
private monsterAiAggressive(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
session.monsterGuardActive = false;
|
||||||
|
const hpRatio = session.monsterHp / session.monsterHpMax;
|
||||||
|
const rageMultiplier = hpRatio < 0.3 ? 1.3 : 1.0;
|
||||||
|
|
||||||
|
this.monsterBasicAttack(session, events, rageMultiplier);
|
||||||
|
session.monsterLastAction = 'attack';
|
||||||
|
|
||||||
|
if (rageMultiplier > 1) {
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Rage',
|
||||||
|
detail: `${session.monsterName} est en rage ! (+30% degats)`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Defensive: alterne attaque/garde, carapace si HP bas ---
|
||||||
|
|
||||||
|
private monsterAiDefensive(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
const hpRatio = session.monsterHp / session.monsterHpMax;
|
||||||
|
|
||||||
|
// Carapace: HP < 40%, cooldown implicit (via guard state)
|
||||||
|
if (hpRatio < 0.4 && !session.monsterGuardActive && session.monsterLastAction !== 'guard') {
|
||||||
|
session.monsterGuardActive = true;
|
||||||
|
session.monsterLastAction = 'guard';
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Carapace',
|
||||||
|
detail: `${session.monsterName} se replie dans sa carapace ! (degats reduits de 80%)`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
// Temporary stronger guard buff
|
||||||
|
session.monsterBuffs.push({
|
||||||
|
id: `guard-${session.round}`,
|
||||||
|
name: 'Carapace',
|
||||||
|
stat: 'damage_reduction',
|
||||||
|
value: 80,
|
||||||
|
isPercent: true,
|
||||||
|
remainingTurns: 1,
|
||||||
|
sourceSpellId: 'monster-carapace',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal: alternate attack/guard
|
||||||
|
if (session.monsterLastAction === 'attack' || session.monsterLastAction === 'none') {
|
||||||
|
session.monsterGuardActive = true;
|
||||||
|
session.monsterLastAction = 'guard';
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Garde',
|
||||||
|
detail: `${session.monsterName} se met en garde. (degats recus -50%)`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
session.monsterGuardActive = false;
|
||||||
|
this.monsterBasicAttack(session, events, 1.0);
|
||||||
|
session.monsterLastAction = 'attack';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chaotic: random weighted ---
|
||||||
|
|
||||||
|
private monsterAiChaotic(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
session.monsterGuardActive = false;
|
||||||
|
const roll = Math.random();
|
||||||
|
|
||||||
|
if (roll < 0.4) {
|
||||||
|
// 40% — attaque normale
|
||||||
|
this.monsterBasicAttack(session, events, 1.0);
|
||||||
|
session.monsterLastAction = 'attack';
|
||||||
|
} else if (roll < 0.7) {
|
||||||
|
// 30% — attaque aleatoire (peut cibler compagnon plus tard)
|
||||||
|
this.monsterBasicAttack(session, events, 0.8 + Math.random() * 0.6);
|
||||||
|
session.monsterLastAction = 'attack';
|
||||||
|
} else if (roll < 0.9) {
|
||||||
|
// 20% — debuff poison
|
||||||
|
const poisonDmg = Math.max(1, Math.floor(session.playerHpMax * 0.05));
|
||||||
|
session.activeDebuffs.push({
|
||||||
|
id: `poison-${session.round}`,
|
||||||
|
name: 'Poison',
|
||||||
|
stat: 'poison',
|
||||||
|
value: poisonDmg,
|
||||||
|
isPercent: false,
|
||||||
|
remainingTurns: 3,
|
||||||
|
sourceSpellId: 'monster-poison',
|
||||||
|
});
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Poison',
|
||||||
|
detail: `${session.monsterName} empoisonne ${session.playerName} ! (${poisonDmg} degats/tour, 3 tours)`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
session.monsterLastAction = 'attack';
|
||||||
|
} else {
|
||||||
|
// 10% — rate son tour
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Hesitation',
|
||||||
|
detail: `${session.monsterName} hesite et perd son tour !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
session.monsterLastAction = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Basic monster attack ---
|
||||||
|
|
||||||
|
private monsterBasicAttack(
|
||||||
|
session: CombatSession,
|
||||||
|
events: TurnLogEntry[],
|
||||||
|
multiplier: number,
|
||||||
|
) {
|
||||||
|
// Esquive joueur
|
||||||
|
const isDodged = rollDodge(session.playerChance);
|
||||||
|
if (isDodged) {
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `${session.playerName} esquive l'attaque de ${session.monsterName} !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
this.checkRiposte(session, events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let damage = calcMonsterDamage(
|
||||||
|
this.buildMonsterStats(session),
|
||||||
|
session.playerDefense,
|
||||||
|
);
|
||||||
|
damage = Math.floor(damage * multiplier);
|
||||||
|
|
||||||
|
// Companion taunt — redirect to companion
|
||||||
|
if (companionAbsorbAttack(session, damage, events)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player damage reduction buffs
|
||||||
|
const dmgReduction = this.spellSystem.getBuffModifier(
|
||||||
|
session.activeBuffs,
|
||||||
|
'damage_reduction',
|
||||||
|
);
|
||||||
|
if (dmgReduction > 0) {
|
||||||
|
damage = Math.floor(damage * (1 - dmgReduction / 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shield
|
||||||
|
const shieldIdx = session.activeBuffs.findIndex((b) => b.stat === 'shield');
|
||||||
|
if (shieldIdx >= 0) {
|
||||||
|
session.activeBuffs.splice(shieldIdx, 1);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `Le bouclier de ${session.playerName} absorbe l'attaque !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
this.checkRiposte(session, events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
damage = Math.max(1, damage);
|
||||||
|
session.playerHp = Math.max(0, session.playerHp - damage);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.monsterName,
|
||||||
|
action: 'Attaque',
|
||||||
|
detail: `${session.monsterName} attaque ${session.playerName} pour ${damage} degats.`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.checkRiposte(session, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RIPOSTE ==========
|
||||||
|
|
||||||
|
private checkRiposte(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
const idx = session.activeBuffs.findIndex((b) => b.stat === 'riposte');
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
const riposte = session.activeBuffs[idx];
|
||||||
|
const damage = Math.floor(session.playerForce * riposte.value);
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp - damage);
|
||||||
|
session.activeBuffs.splice(idx, 1);
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Contre-Courant',
|
||||||
|
detail: `${session.playerName} riposte pour ${damage} degats !`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== REGEN TICK ==========
|
||||||
|
|
||||||
|
private tickRegenBuffs(session: CombatSession, events: TurnLogEntry[]) {
|
||||||
|
// HP regen
|
||||||
|
const regenBuff = session.activeBuffs.find((b) => b.stat === 'regen');
|
||||||
|
if (regenBuff) {
|
||||||
|
const amount = Math.floor(session.playerHpMax * (regenBuff.value / 100));
|
||||||
|
session.playerHp = Math.min(session.playerHpMax, session.playerHp + amount);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Regen',
|
||||||
|
detail: `${session.playerName} regenere ${amount} HP.`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poison tick
|
||||||
|
const poison = session.activeDebuffs.find((d) => d.stat === 'poison');
|
||||||
|
if (poison) {
|
||||||
|
const damage = poison.value;
|
||||||
|
session.playerHp = Math.max(0, session.playerHp - damage);
|
||||||
|
events.push({
|
||||||
|
round: session.round,
|
||||||
|
actor: session.playerName,
|
||||||
|
action: 'Poison',
|
||||||
|
detail: `${session.playerName} subit ${damage} degats de poison.`,
|
||||||
|
hpAfter: { player: session.playerHp, monster: session.monsterHp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FINISH COMBAT + PERSIST ==========
|
||||||
|
|
||||||
|
private async finishCombat(
|
||||||
|
session: CombatSession,
|
||||||
|
events: TurnLogEntry[],
|
||||||
|
winner: 'player' | 'monster',
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
session.monsterHp = Math.max(0, session.monsterHp);
|
||||||
|
session.playerHp = Math.max(0, session.playerHp);
|
||||||
|
session.status = 'finished';
|
||||||
|
session.log.push(...events);
|
||||||
|
|
||||||
|
// Persist character state
|
||||||
|
const result = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const character = await manager
|
||||||
|
.getRepository(Character)
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.where('c.id = :id', { id: session.characterId })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!character) return null;
|
||||||
|
|
||||||
|
let rewards: TurnResult['rewards'];
|
||||||
|
|
||||||
|
if (winner === 'player') {
|
||||||
|
const xpEarned = session.xpReward;
|
||||||
|
const goldEarned =
|
||||||
|
session.goldMin +
|
||||||
|
Math.floor(Math.random() * (session.goldMax - session.goldMin + 1));
|
||||||
|
|
||||||
|
const levelUp = applyXpGain(character.level, character.xp, xpEarned);
|
||||||
|
character.xp = levelUp.newXp;
|
||||||
|
character.level = levelUp.newLevel;
|
||||||
|
character.statPoints = (character.statPoints ?? 0) + levelUp.statPointsGained;
|
||||||
|
character.gold += goldEarned;
|
||||||
|
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + goldEarned;
|
||||||
|
character.hpCurrent = Math.min(
|
||||||
|
character.hpMax,
|
||||||
|
session.playerHp + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO),
|
||||||
|
);
|
||||||
|
character.manaCurrent = session.playerMana;
|
||||||
|
|
||||||
|
rewards = {
|
||||||
|
xp: xpEarned,
|
||||||
|
gold: goldEarned,
|
||||||
|
levelUp: levelUp.levelsGained > 0,
|
||||||
|
newLevel: levelUp.newLevel,
|
||||||
|
statPointsGained: levelUp.statPointsGained,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combat log
|
||||||
|
await manager.save(
|
||||||
|
this.combatLogRepo.create({
|
||||||
|
characterId: character.id,
|
||||||
|
monsterId: session.monsterId,
|
||||||
|
winner: 'player',
|
||||||
|
totalRounds: session.round,
|
||||||
|
roundsData: session.log,
|
||||||
|
xpEarned,
|
||||||
|
goldEarned,
|
||||||
|
levelUp: levelUp.levelsGained > 0,
|
||||||
|
lootMaterialId: null,
|
||||||
|
lootQuantity: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Defaite
|
||||||
|
const elapsed = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||||
|
const recharge = Math.floor(elapsed / 3);
|
||||||
|
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||||
|
character.enduranceSaved = Math.max(0, enduranceCurrent - DEFEAT_ENDURANCE_PENALTY);
|
||||||
|
character.lastEnduranceTs = new Date();
|
||||||
|
character.hpCurrent = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO));
|
||||||
|
const goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO);
|
||||||
|
character.gold = Math.max(0, character.gold - goldLost);
|
||||||
|
character.manaCurrent = session.playerMana;
|
||||||
|
|
||||||
|
await manager.save(
|
||||||
|
this.combatLogRepo.create({
|
||||||
|
characterId: character.id,
|
||||||
|
monsterId: session.monsterId,
|
||||||
|
winner: 'monster',
|
||||||
|
totalRounds: session.round,
|
||||||
|
roundsData: session.log,
|
||||||
|
xpEarned: 0,
|
||||||
|
goldEarned: 0,
|
||||||
|
levelUp: false,
|
||||||
|
lootMaterialId: null,
|
||||||
|
lootQuantity: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.save(character);
|
||||||
|
return rewards;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events post-transaction
|
||||||
|
if (winner === 'player') {
|
||||||
|
const cid = session.characterId;
|
||||||
|
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: 1 });
|
||||||
|
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: 1 });
|
||||||
|
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: 1 });
|
||||||
|
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: session.monsterId, increment: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildTurnResult(session, winner, result ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GET SESSION ==========
|
||||||
|
|
||||||
|
getSession(sessionId: string, userId: string): TurnResult | null {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session || session.playerId !== userId) return null;
|
||||||
|
return this.buildTurnResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
|
private buildPlayerStats(session: CombatSession): CombatantStats {
|
||||||
|
return {
|
||||||
|
name: session.playerName,
|
||||||
|
hpCurrent: session.playerHp,
|
||||||
|
hpMax: session.playerHpMax,
|
||||||
|
force: session.playerForce,
|
||||||
|
agilite: session.playerAgilite,
|
||||||
|
intelligence: session.playerIntelligence,
|
||||||
|
chance: session.playerChance,
|
||||||
|
attack: session.playerAttack,
|
||||||
|
defense: session.playerDefense,
|
||||||
|
attackType: session.attackType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMonsterStats(session: CombatSession): CombatantStats {
|
||||||
|
return {
|
||||||
|
name: session.monsterName,
|
||||||
|
hpCurrent: session.monsterHp,
|
||||||
|
hpMax: session.monsterHpMax,
|
||||||
|
force: 0,
|
||||||
|
agilite: 0,
|
||||||
|
intelligence: 0,
|
||||||
|
chance: 0,
|
||||||
|
attack: session.monsterAttack,
|
||||||
|
defense: session.monsterDefense,
|
||||||
|
attackType: 'melee',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTurnResult(
|
||||||
|
session: CombatSession,
|
||||||
|
winner?: 'player' | 'monster',
|
||||||
|
rewards?: TurnResult['rewards'],
|
||||||
|
): TurnResult {
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
round: session.round,
|
||||||
|
playerName: session.playerName,
|
||||||
|
monsterName: session.monsterName,
|
||||||
|
events: session.log.slice(-15),
|
||||||
|
playerHp: session.playerHp,
|
||||||
|
playerHpMax: session.playerHpMax,
|
||||||
|
playerMana: session.playerMana,
|
||||||
|
playerManaMax: session.playerManaMax,
|
||||||
|
monsterHp: session.monsterHp,
|
||||||
|
monsterHpMax: session.monsterHpMax,
|
||||||
|
companion: session.companion
|
||||||
|
? {
|
||||||
|
name: session.companion.name,
|
||||||
|
type: session.companion.type,
|
||||||
|
hpCurrent: session.companion.hpCurrent,
|
||||||
|
hpMax: session.companion.hpMax,
|
||||||
|
manaCurrent: session.companion.manaCurrent,
|
||||||
|
manaMax: session.companion.manaMax,
|
||||||
|
activeBuffs: session.companion.activeBuffs,
|
||||||
|
activeDebuffs: session.companion.activeDebuffs,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
activeBuffs: session.activeBuffs,
|
||||||
|
activeDebuffs: session.activeDebuffs,
|
||||||
|
monsterBuffs: session.monsterBuffs,
|
||||||
|
monsterDebuffs: session.monsterDebuffs,
|
||||||
|
spellCooldowns: session.spellCooldowns,
|
||||||
|
bossPhase: session.bossPhase,
|
||||||
|
status: session.status,
|
||||||
|
...(winner && { winner }),
|
||||||
|
...(rewards && { rewards }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupExpired() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, session] of this.sessions) {
|
||||||
|
if (now - session.createdAt > SESSION_TTL_MS) {
|
||||||
|
this.sessions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/combat/turn/types.ts
Normal file
181
src/combat/turn/types.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// ---------- Dao & Sorts ----------
|
||||||
|
|
||||||
|
export type DaoPath = 'ecoute' | 'resonance' | 'harmonie';
|
||||||
|
|
||||||
|
export type SpellTargetType = 'enemy' | 'self' | 'ally' | 'all_enemies' | 'all_allies';
|
||||||
|
|
||||||
|
export interface SpellDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: DaoPath;
|
||||||
|
pathLevel: number; // niveau requis dans la voie (1-5)
|
||||||
|
manaCost: number;
|
||||||
|
cooldown: number; // en tours
|
||||||
|
targetType: SpellTargetType;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpellEffect {
|
||||||
|
type: 'damage' | 'heal' | 'buff' | 'debuff' | 'purge' | 'special';
|
||||||
|
stat?: string; // stat concernee (force, defense, precision...)
|
||||||
|
value?: number; // valeur absolue ou ratio selon le contexte
|
||||||
|
ratio?: number; // multiplicateur de stat (ex: Int * 2)
|
||||||
|
ratioStat?: string; // stat source du ratio
|
||||||
|
isPercent?: boolean; // true = valeur en %, false = flat
|
||||||
|
duration?: number; // en tours (pour buff/debuff)
|
||||||
|
log: string; // template de texte combat
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Buffs / Debuffs ----------
|
||||||
|
|
||||||
|
export interface Buff {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stat: string;
|
||||||
|
value: number; // +/- en % ou flat selon le type
|
||||||
|
isPercent: boolean;
|
||||||
|
remainingTurns: number;
|
||||||
|
sourceSpellId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Debuff {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stat: string;
|
||||||
|
value: number;
|
||||||
|
isPercent: boolean;
|
||||||
|
remainingTurns: number;
|
||||||
|
sourceSpellId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Actions ----------
|
||||||
|
|
||||||
|
export type TurnActionType = 'attack' | 'spell' | 'item' | 'flee';
|
||||||
|
|
||||||
|
export interface TurnAction {
|
||||||
|
type: TurnActionType;
|
||||||
|
spellId?: string;
|
||||||
|
itemId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Session de combat ----------
|
||||||
|
|
||||||
|
export type CombatSessionStatus = 'awaiting_player' | 'resolving' | 'finished';
|
||||||
|
|
||||||
|
export interface CompanionState {
|
||||||
|
name: string;
|
||||||
|
type: 'mira' | 'vell';
|
||||||
|
hpCurrent: number;
|
||||||
|
hpMax: number;
|
||||||
|
manaCurrent: number;
|
||||||
|
manaMax: number;
|
||||||
|
force: number;
|
||||||
|
agilite: number;
|
||||||
|
intelligence: number;
|
||||||
|
chance: number;
|
||||||
|
activeBuffs: Buff[];
|
||||||
|
activeDebuffs: Debuff[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatSession {
|
||||||
|
id: string;
|
||||||
|
playerId: string;
|
||||||
|
characterId: string;
|
||||||
|
playerName: string;
|
||||||
|
playerHp: number;
|
||||||
|
playerHpMax: number;
|
||||||
|
playerMana: number;
|
||||||
|
playerManaMax: number;
|
||||||
|
playerForce: number;
|
||||||
|
playerAgilite: number;
|
||||||
|
playerIntelligence: number;
|
||||||
|
playerChance: number;
|
||||||
|
playerAttack: number;
|
||||||
|
playerDefense: number;
|
||||||
|
attackType: import('../../monster/monster.entity').AttackType;
|
||||||
|
monsterName: string;
|
||||||
|
monsterId: string;
|
||||||
|
monsterHp: number;
|
||||||
|
monsterHpMax: number;
|
||||||
|
monsterAttack: number;
|
||||||
|
monsterDefense: number;
|
||||||
|
monsterAiProfile: MonsterAiProfile;
|
||||||
|
monsterGuardActive: boolean; // defensive AI — alternating guard
|
||||||
|
monsterLastAction: 'attack' | 'guard' | 'none';
|
||||||
|
isBoss: boolean;
|
||||||
|
bossPhase: number;
|
||||||
|
xpReward: number;
|
||||||
|
goldMin: number;
|
||||||
|
goldMax: number;
|
||||||
|
companion: CompanionState | null;
|
||||||
|
activeBuffs: Buff[];
|
||||||
|
activeDebuffs: Debuff[];
|
||||||
|
monsterBuffs: Buff[];
|
||||||
|
monsterDebuffs: Debuff[];
|
||||||
|
spellCooldowns: Record<string, number>; // spellId -> tours restants
|
||||||
|
round: number;
|
||||||
|
log: TurnLogEntry[];
|
||||||
|
status: CombatSessionStatus;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnLogEntry {
|
||||||
|
round: number;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
detail: string;
|
||||||
|
hpAfter: { player: number; monster: number; companion?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Monster AI ----------
|
||||||
|
|
||||||
|
export type MonsterAiProfile = 'aggressive' | 'defensive' | 'chaotic' | 'boss';
|
||||||
|
|
||||||
|
// ---------- Turn resolution result (retourne au client) ----------
|
||||||
|
|
||||||
|
export interface TurnResult {
|
||||||
|
sessionId: string;
|
||||||
|
round: number;
|
||||||
|
playerName: string;
|
||||||
|
monsterName: string;
|
||||||
|
events: TurnLogEntry[];
|
||||||
|
playerHp: number;
|
||||||
|
playerHpMax: number;
|
||||||
|
playerMana: number;
|
||||||
|
playerManaMax: number;
|
||||||
|
monsterHp: number;
|
||||||
|
monsterHpMax: number;
|
||||||
|
companion?: {
|
||||||
|
name: string;
|
||||||
|
type: 'mira' | 'vell';
|
||||||
|
hpCurrent: number;
|
||||||
|
hpMax: number;
|
||||||
|
manaCurrent: number;
|
||||||
|
manaMax: number;
|
||||||
|
activeBuffs: Buff[];
|
||||||
|
activeDebuffs: Debuff[];
|
||||||
|
} | null;
|
||||||
|
activeBuffs: Buff[];
|
||||||
|
activeDebuffs: Debuff[];
|
||||||
|
monsterBuffs: Buff[];
|
||||||
|
monsterDebuffs: Debuff[];
|
||||||
|
spellCooldowns: Record<string, number>;
|
||||||
|
bossPhase: number;
|
||||||
|
status: CombatSessionStatus;
|
||||||
|
winner?: 'player' | 'monster';
|
||||||
|
/** Rewards populated when status === 'finished' && winner === 'player' */
|
||||||
|
rewards?: {
|
||||||
|
xp: number;
|
||||||
|
gold: number;
|
||||||
|
levelUp: boolean;
|
||||||
|
newLevel: number;
|
||||||
|
statPointsGained: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MANA_REGEN_PER_TURN = 5;
|
||||||
|
export const BASE_MANA = 50;
|
||||||
|
export const MANA_PER_INTELLIGENCE = 2;
|
||||||
|
export const FLEE_BASE_CHANCE = 0.5;
|
||||||
|
export const FLEE_AGILITY_BONUS = 0.005;
|
||||||
|
export const SESSION_TTL_MS = 10 * 60 * 1000; // 10 min
|
||||||
147
src/database/migrations/1743004800000-TurnCombatSystem.ts
Normal file
147
src/database/migrations/1743004800000-TurnCombatSystem.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class TurnCombatSystem1743004800000 implements MigrationInterface {
|
||||||
|
name = 'TurnCombatSystem1743004800000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// --- Mana sur characters ---
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE \`characters\`
|
||||||
|
ADD COLUMN \`mana_current\` INT NOT NULL DEFAULT 50 AFTER \`hp_max\`,
|
||||||
|
ADD COLUMN \`mana_max\` INT NOT NULL DEFAULT 50 AFTER \`mana_current\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
// --- AI profile sur monsters ---
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE \`monsters\`
|
||||||
|
ADD COLUMN \`ai_profile\` VARCHAR(20) NOT NULL DEFAULT 'aggressive' AFTER \`zone\`,
|
||||||
|
ADD COLUMN \`is_boss\` TINYINT(1) NOT NULL DEFAULT 0 AFTER \`ai_profile\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
// --- Table des sorts ---
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE \`spells\` (
|
||||||
|
\`id\` VARCHAR(36) NOT NULL,
|
||||||
|
\`name\` VARCHAR(100) NOT NULL,
|
||||||
|
\`path\` VARCHAR(20) NOT NULL,
|
||||||
|
\`path_level\` INT NOT NULL,
|
||||||
|
\`mana_cost\` INT NOT NULL,
|
||||||
|
\`cooldown\` INT NOT NULL,
|
||||||
|
\`target_type\` VARCHAR(20) NOT NULL,
|
||||||
|
\`description\` TEXT NOT NULL,
|
||||||
|
\`effects\` JSON NOT NULL,
|
||||||
|
\`unlock_cost\` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
|
||||||
|
// --- Sorts debloques par joueur ---
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE \`player_spells\` (
|
||||||
|
\`id\` VARCHAR(36) NOT NULL,
|
||||||
|
\`character_id\` VARCHAR(36) NOT NULL,
|
||||||
|
\`spell_id\` VARCHAR(36) NOT NULL,
|
||||||
|
\`unlocked_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE INDEX \`IDX_player_spells_char_spell\` (\`character_id\`, \`spell_id\`),
|
||||||
|
INDEX \`IDX_player_spells_character\` (\`character_id\`),
|
||||||
|
CONSTRAINT \`FK_player_spells_character\` FOREIGN KEY (\`character_id\`)
|
||||||
|
REFERENCES \`characters\`(\`id\`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT \`FK_player_spells_spell\` FOREIGN KEY (\`spell_id\`)
|
||||||
|
REFERENCES \`spells\`(\`id\`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
|
||||||
|
// --- Progression dans les voies du Dao ---
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE \`player_dao_paths\` (
|
||||||
|
\`id\` VARCHAR(36) NOT NULL,
|
||||||
|
\`character_id\` VARCHAR(36) NOT NULL,
|
||||||
|
\`path\` VARCHAR(20) NOT NULL,
|
||||||
|
\`is_primary\` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
\`path_points\` INT NOT NULL DEFAULT 0,
|
||||||
|
\`path_level\` INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE INDEX \`IDX_player_dao_char_path\` (\`character_id\`, \`path\`),
|
||||||
|
INDEX \`IDX_player_dao_character\` (\`character_id\`),
|
||||||
|
CONSTRAINT \`FK_player_dao_character\` FOREIGN KEY (\`character_id\`)
|
||||||
|
REFERENCES \`characters\`(\`id\`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB
|
||||||
|
`);
|
||||||
|
|
||||||
|
// --- Seed des 15 sorts ---
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO \`spells\` (\`id\`, \`name\`, \`path\`, \`path_level\`, \`mana_cost\`, \`cooldown\`, \`target_type\`, \`description\`, \`effects\`, \`unlock_cost\`) VALUES
|
||||||
|
-- Ecoute
|
||||||
|
(UUID(), 'Perception du Flux', 'ecoute', 1, 10, 3, 'enemy',
|
||||||
|
'Revele les faiblesses de l''ennemi. Buff +20% degats pendant 2 tours.',
|
||||||
|
'[{"type":"buff","stat":"damage","value":20,"isPercent":true,"duration":2,"log":"{caster} percoit les failles de {target} !"}]', 0),
|
||||||
|
|
||||||
|
(UUID(), 'Chant d''Eveil', 'ecoute', 2, 20, 2, 'enemy',
|
||||||
|
'Degats magiques + debuff Confusion (-30% precision, 2 tours).',
|
||||||
|
'[{"type":"damage","ratio":2,"ratioStat":"intelligence","log":"{caster} entonne le Chant d''Eveil — {damage} degats !"},{"type":"debuff","stat":"precision","value":30,"isPercent":true,"duration":2,"log":"{target} est Confus !"}]', 3),
|
||||||
|
|
||||||
|
(UUID(), 'Ancrage Memoriel', 'ecoute', 3, 15, 4, 'self',
|
||||||
|
'Annule le prochain debuff ou purifie un debuff actif.',
|
||||||
|
'[{"type":"purge","stat":"debuff","value":1,"log":"{caster} ancre sa memoire — debuff annule !"}]', 6),
|
||||||
|
|
||||||
|
(UUID(), 'Murmure du Courant', 'ecoute', 4, 25, 3, 'enemy',
|
||||||
|
'Degats magiques (Int x2.5) + drain mana si ennemi caster.',
|
||||||
|
'[{"type":"damage","ratio":2.5,"ratioStat":"intelligence","log":"{caster} murmure au Courant — {damage} degats !"},{"type":"special","stat":"mana_drain","value":15,"log":"Le Courant aspire l''energie de {target} !"}]', 10),
|
||||||
|
|
||||||
|
(UUID(), 'Chant de l''Oubli', 'ecoute', 5, 35, 5, 'enemy',
|
||||||
|
'Reset cooldowns ennemis + degats (Int x3). Boss : -1 buff au lieu du reset.',
|
||||||
|
'[{"type":"damage","ratio":3,"ratioStat":"intelligence","log":"{caster} libere le Chant de l''Oubli — {damage} degats !"},{"type":"special","stat":"cooldown_reset","value":0,"log":"Les capacites de {target} sont perturbees !"}]', 15),
|
||||||
|
|
||||||
|
-- Resonance
|
||||||
|
(UUID(), 'Onde de Choc', 'resonance', 1, 15, 2, 'all_enemies',
|
||||||
|
'Degats physiques AoE (Force x1.5) a tous les ennemis.',
|
||||||
|
'[{"type":"damage","ratio":1.5,"ratioStat":"force","log":"{caster} declenche une Onde de Choc — {damage} degats !"}]', 0),
|
||||||
|
|
||||||
|
(UUID(), 'Bouclier de Flux', 'resonance', 2, 20, 4, 'self',
|
||||||
|
'Reduit les degats recus de 40% pendant 2 tours.',
|
||||||
|
'[{"type":"buff","stat":"damage_reduction","value":40,"isPercent":true,"duration":2,"log":"{caster} erige un Bouclier de Flux !"}]', 3),
|
||||||
|
|
||||||
|
(UUID(), 'Contre-Courant', 'resonance', 3, 15, 3, 'self',
|
||||||
|
'Riposte automatique au prochain coup recu (Force x2).',
|
||||||
|
'[{"type":"buff","stat":"riposte","value":2,"isPercent":false,"duration":1,"log":"{caster} se prepare a la riposte !"}]', 6),
|
||||||
|
|
||||||
|
(UUID(), 'Ancre de Pierre', 'resonance', 4, 25, 4, 'self',
|
||||||
|
'Taunt + boost defense 50% pendant 2 tours.',
|
||||||
|
'[{"type":"buff","stat":"taunt","value":1,"isPercent":false,"duration":2,"log":"{caster} s''ancre dans la pierre !"},{"type":"buff","stat":"defense","value":50,"isPercent":true,"duration":2,"log":"Defense renforcee !"}]', 10),
|
||||||
|
|
||||||
|
(UUID(), 'Fracture Sismique', 'resonance', 5, 40, 5, 'enemy',
|
||||||
|
'Degats massifs (Force x3.5) + Stun 1 tour.',
|
||||||
|
'[{"type":"damage","ratio":3.5,"ratioStat":"force","log":"{caster} fracture le sol — {damage} degats !"},{"type":"debuff","stat":"stun","value":1,"isPercent":false,"duration":1,"log":"{target} est etourdi !"}]', 15),
|
||||||
|
|
||||||
|
-- Harmonie
|
||||||
|
(UUID(), 'Chant Apaisant', 'harmonie', 1, 15, 2, 'ally',
|
||||||
|
'Soin (Int x2 + 10% hpMax).',
|
||||||
|
'[{"type":"heal","ratio":2,"ratioStat":"intelligence","value":10,"log":"{caster} entonne un chant apaisant — {target} recupere {heal} HP !"}]', 0),
|
||||||
|
|
||||||
|
(UUID(), 'Dissolution', 'harmonie', 2, 20, 3, 'enemy',
|
||||||
|
'Retire tous les buffs d''un ennemi.',
|
||||||
|
'[{"type":"purge","stat":"buff","value":99,"log":"{caster} dissout les protections de {target} !"}]', 3),
|
||||||
|
|
||||||
|
(UUID(), 'Onde de Serenite', 'harmonie', 3, 25, 4, 'all_allies',
|
||||||
|
'Buff defense +25% + regen 5% hpMax/tour (3 tours) a toute l''equipe.',
|
||||||
|
'[{"type":"buff","stat":"defense","value":25,"isPercent":true,"duration":3,"log":"Une onde de serenite enveloppe l''equipe !"},{"type":"buff","stat":"regen","value":5,"isPercent":true,"duration":3,"log":"Regeneration active !"}]', 6),
|
||||||
|
|
||||||
|
(UUID(), 'Lien du Courant', 'harmonie', 4, 20, 3, 'ally',
|
||||||
|
'Transfere 30% des degats du joueur au compagnon (ou inverse) pendant 3 tours.',
|
||||||
|
'[{"type":"buff","stat":"damage_link","value":30,"isPercent":true,"duration":3,"log":"{caster} tisse un lien de Courant avec {target} !"}]', 10),
|
||||||
|
|
||||||
|
(UUID(), 'Symphonie Restauratrice', 'harmonie', 5, 45, 6, 'all_allies',
|
||||||
|
'Full heal equipe + purge tous debuffs + bouclier 1 coup.',
|
||||||
|
'[{"type":"heal","ratio":0,"ratioStat":"intelligence","value":100,"log":"La Symphonie Restauratrice guerit toute l''equipe !"},{"type":"purge","stat":"debuff","value":99,"log":"Tous les debuffs sont purges !"},{"type":"buff","stat":"shield","value":1,"isPercent":false,"duration":1,"log":"Un bouclier protege chacun du prochain coup !"}]', 15)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS \`player_spells\``);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS \`player_dao_paths\``);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS \`spells\``);
|
||||||
|
await queryRunner.query(`ALTER TABLE \`monsters\` DROP COLUMN \`is_boss\`, DROP COLUMN \`ai_profile\``);
|
||||||
|
await queryRunner.query(`ALTER TABLE \`characters\` DROP COLUMN \`mana_max\`, DROP COLUMN \`mana_current\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,4 +42,10 @@ export class Monster {
|
|||||||
|
|
||||||
@Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' })
|
@Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' })
|
||||||
zone: string;
|
zone: string;
|
||||||
|
|
||||||
|
@Column({ name: 'ai_profile', type: 'varchar', length: 20, default: 'aggressive' })
|
||||||
|
aiProfile: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_boss', default: false })
|
||||||
|
isBoss: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user