diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1eb1584..0ae99c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage'; import { AuthCallback } from './pages/AuthCallback'; import { DashboardPage } from './pages/DashboardPage'; import { CombatPage } from './pages/CombatPage'; +import { TurnCombatPage } from './pages/TurnCombatPage'; import { InventoryPage } from './pages/InventoryPage'; import { CraftPage } from './pages/CraftPage'; import { ForgePage } from './pages/ForgePage'; @@ -38,6 +39,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 7d6a08a..457de2e 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -2,6 +2,7 @@ import { api } from './client'; import type { User, Character, Monster, CombatLog, CharacterItem, CharacterMaterial, Recipe, CraftJob, Item, + TurnResult, TurnSpell, DaoPathProgress, } from './types'; // Auth @@ -31,6 +32,23 @@ export const combatApi = { history: () => api.get('/combat/history'), }; +// Turn Combat +export const turnCombatApi = { + start: (monsterId: string, attackType: string, companion?: string | null) => + api.post('/combat/turn/start', { monsterId, attackType, ...(companion ? { companion } : {}) }), + action: (sessionId: string, type: string, spellId?: string) => + api.post('/combat/turn/action', { sessionId, type, ...(spellId ? { spellId } : {}) }), + session: (sessionId: string) => + api.get(`/combat/turn/session/${sessionId}`), + spells: () => api.get('/combat/turn/spells'), + unlockedSpells: () => api.get('/combat/turn/spells/unlocked'), + unlockSpell: (spellId: string) => + api.post('/combat/turn/spells/unlock', { spellId }), + dao: () => api.get('/combat/turn/dao'), + chooseDaoPath: (path: string) => + api.post('/combat/turn/dao/choose', { path }), +}; + // Items export const itemApi = { catalogue: () => api.get('/items'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index bed7933..3894bcf 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -113,6 +113,85 @@ export interface CombatLog { 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; + 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 interface Item { diff --git a/frontend/src/pages/CombatPage.tsx b/frontend/src/pages/CombatPage.tsx index c4c475b..6aeb270 100644 --- a/frontend/src/pages/CombatPage.tsx +++ b/frontend/src/pages/CombatPage.tsx @@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { combatApi, characterApi } from '../api/endpoints'; 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 { MonsterCard } from '../components/MonsterCard'; import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews'; @@ -85,7 +86,12 @@ export function CombatPage() { return (
-

⚔️ Combat

+
+

⚔️ Combat

+ + Combat Tactique + +
{/* Choix monstre par zone */} diff --git a/frontend/src/pages/TurnCombatPage.tsx b/frontend/src/pages/TurnCombatPage.tsx new file mode 100644 index 0000000..4bc5e5f --- /dev/null +++ b/frontend/src/pages/TurnCombatPage.tsx @@ -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('select'); + const [selectedMonster, setSelectedMonster] = useState(null); + const [attackType, setAttackType] = useState('melee'); + const [combat, setCombat] = useState(null); + const [companion, setCompanion] = useState<'mira' | 'vell' | null>(null); + const [spellMenuOpen, setSpellMenuOpen] = useState(false); + const logRef = useRef(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(); + 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 ( +
+

+ + Combat Tactique +

+ +
+
+ {Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => { + const info = ZONE_INFO[zone] ?? { name: zone, emoji: '📍' }; + return ( +
+

+ {info.emoji} {info.name} +

+
+ {zoneMonsters.sort((a, b) => a.minLevel - b.minLevel).map(m => ( + setSelectedMonster(m)} + playerLevel={char?.level ?? 1} + /> + ))} +
+
+ ); + })} + {lockedZones.map((z: any) => ( +
+ {z.emoji} {z.name} — Verrouillee +
+ ))} +
+ +
+

+ Type d'attaque +

+
+ {ATTACK_TYPES.map(a => ( +
setAttackType(a.id)} + style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }} + > + {a.emoji} +
+
{a.label}
+
{a.stat}
+
+
+ ))} +
+ + {/* Compagnon */} +

+ + Compagnon (optionnel) +

+
+ {[ + { 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 => ( +
setCompanion(c.id)} + style={{ flex: 1, cursor: 'pointer', textAlign: 'center', padding: '0.5rem' }} + > +
{c.emoji}
+
{c.label}
+ {c.desc &&
{c.desc}
} +
+ ))} +
+ +
+ Cout : {COMBAT_COST} endurance — Dispo : {endurance} +
+ + +
+
+
+ ); + } + + // ========== 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 ( +
+

+ Tour {combat.round} +

+ + {/* Combatants */} +
+ {/* Player */} +
+
+ {combat.playerName} +
+ + + +
+ + {/* Companion */} + {combat.companion && ( +
+
+ {combat.companion.type === 'mira' ? '🌊' : '🪨'} {combat.companion.name} + {combat.companion.hpCurrent <= 0 && ' (KO)'} +
+ + + +
+ )} + + {/* Monster */} +
+
+ {combat.monsterName} +
+ + +
+
+ + {/* Log */} +
+ {combat.events.length === 0 && ( +

Le combat commence...

+ )} + {combat.events.map((e, i) => ( +
+ [T{e.round}] {e.detail} +
+ ))} +
+ + {/* Actions */} + {!spellMenuOpen ? ( +
+ + + + +
+ ) : ( +
+
+ {(spells ?? []).map(spell => { + const cd = combat.spellCooldowns[spell.id] ?? 0; + const notEnoughMana = combat.playerMana < spell.manaCost; + const disabled = isActing || cd > 0 || notEnoughMana; + return ( + + ); + })} +
+ +
+ )} +
+ ); + } + + // ========== PHASE: RESULT ========== + if (phase === 'result' && combat) { + const won = combat.winner === 'player'; + return ( +
+
+ {won ? : } +
+

+ {won ? 'Victoire !' : 'Defaite...'} +

+

+ Combat termine en {combat.round} tour{combat.round > 1 ? 's' : ''} +

+ + {won && combat.rewards && ( +
+
+ +{combat.rewards.xp} XP   +{combat.rewards.gold} Or +
+ {combat.rewards.levelUp && ( +
+ LEVEL UP ! Niveau {combat.rewards.newLevel} (+{combat.rewards.statPointsGained} stat points) +
+ )} +
+ )} + +
+ +
+
+ ); + } + + return null; +} + +// ========== Sous-composants ========== + +function BarDisplay({ label, value, max, pct, color }: { label: string; value: number; max: number; pct: number; color: string }) { + return ( +
+
+ {label} + {value}/{max} +
+
+
+
+
+ ); +} + +function BuffList({ buffs, debuffs }: { buffs: TurnBuff[]; debuffs: TurnBuff[] }) { + if (!buffs.length && !debuffs.length) return null; + return ( +
+ {buffs.map(b => ( + + + {b.name} ({b.remainingTurns}) + + ))} + {debuffs.map(d => ( + + {d.name} ({d.remainingTurns}) + + ))} +
+ ); +} + +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'; + } +} diff --git a/src/character/entities/character.entity.ts b/src/character/entities/character.entity.ts index 0364074..1ba6c96 100644 --- a/src/character/entities/character.entity.ts +++ b/src/character/entities/character.entity.ts @@ -57,6 +57,13 @@ export class Character { @Column({ name: 'hp_max', default: 100 }) 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) @Column({ name: 'endurance_saved', default: 100 }) enduranceSaved: number; diff --git a/src/combat/combat.module.ts b/src/combat/combat.module.ts index 865d8b6..3b6673f 100644 --- a/src/combat/combat.module.ts +++ b/src/combat/combat.module.ts @@ -9,17 +9,23 @@ import { AuthModule } from '../auth/auth.module'; import { ItemModule } from '../item/item.module'; import { MaterialModule } from '../material/material.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({ imports: [ - TypeOrmModule.forFeature([Character, CombatLog]), + TypeOrmModule.forFeature([Character, CombatLog, Spell, PlayerSpell, PlayerDaoPath]), MonsterModule, AuthModule, ItemModule, MaterialModule, CommunityModule, ], - controllers: [CombatController], - providers: [CombatService], + controllers: [CombatController, TurnCombatController], + providers: [CombatService, SpellSystem, TurnCombatService], }) export class CombatModule {} diff --git a/src/combat/turn/companion-ai.ts b/src/combat/turn/companion-ai.ts new file mode 100644 index 0000000..47044cc --- /dev/null +++ b/src/combat/turn/companion-ai.ts @@ -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; +} diff --git a/src/combat/turn/dto/choose-dao-path.dto.ts b/src/combat/turn/dto/choose-dao-path.dto.ts new file mode 100644 index 0000000..3dd26c4 --- /dev/null +++ b/src/combat/turn/dto/choose-dao-path.dto.ts @@ -0,0 +1,7 @@ +import { IsIn } from 'class-validator'; +import { DaoPath } from '../types'; + +export class ChooseDaoPathDto { + @IsIn(['ecoute', 'resonance', 'harmonie']) + path: DaoPath; +} diff --git a/src/combat/turn/dto/start-turn-combat.dto.ts b/src/combat/turn/dto/start-turn-combat.dto.ts new file mode 100644 index 0000000..8cc9ccd --- /dev/null +++ b/src/combat/turn/dto/start-turn-combat.dto.ts @@ -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; +} diff --git a/src/combat/turn/dto/turn-action.dto.ts b/src/combat/turn/dto/turn-action.dto.ts new file mode 100644 index 0000000..bb21d89 --- /dev/null +++ b/src/combat/turn/dto/turn-action.dto.ts @@ -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; +} diff --git a/src/combat/turn/dto/unlock-spell.dto.ts b/src/combat/turn/dto/unlock-spell.dto.ts new file mode 100644 index 0000000..36be04c --- /dev/null +++ b/src/combat/turn/dto/unlock-spell.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class UnlockSpellDto { + @IsUUID() + spellId: string; +} diff --git a/src/combat/turn/player-dao-path.entity.ts b/src/combat/turn/player-dao-path.entity.ts new file mode 100644 index 0000000..1287204 --- /dev/null +++ b/src/combat/turn/player-dao-path.entity.ts @@ -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; +} diff --git a/src/combat/turn/player-spell.entity.ts b/src/combat/turn/player-spell.entity.ts new file mode 100644 index 0000000..1d9a025 --- /dev/null +++ b/src/combat/turn/player-spell.entity.ts @@ -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; +} diff --git a/src/combat/turn/spell.entity.ts b/src/combat/turn/spell.entity.ts new file mode 100644 index 0000000..55f2884 --- /dev/null +++ b/src/combat/turn/spell.entity.ts @@ -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; +} diff --git a/src/combat/turn/spell.system.ts b/src/combat/turn/spell.system.ts new file mode 100644 index 0000000..2898a32 --- /dev/null +++ b/src/combat/turn/spell.system.ts @@ -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, + @InjectRepository(PlayerSpell) + private readonly playerSpellRepo: Repository, + @InjectRepository(PlayerDaoPath) + private readonly daoPathRepo: Repository, + ) {} + + // ---------- Lecture ---------- + + /** Sorts debloques par le joueur */ + async getUnlockedSpells(characterId: string): Promise { + 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 { + return this.spellRepo.find({ order: { path: 'ASC', pathLevel: 'ASC' } }); + } + + /** Progression du joueur dans les voies */ + async getDaoPaths(characterId: string): Promise { + return this.daoPathRepo.find({ where: { characterId } }); + } + + // ---------- Deblocage ---------- + + async unlockSpell(characterId: string, spellId: string): Promise { + 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 { + // 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 { + // 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; + } +} diff --git a/src/combat/turn/turn-combat.controller.ts b/src/combat/turn/turn-combat.controller.ts new file mode 100644 index 0000000..797bd62 --- /dev/null +++ b/src/combat/turn/turn-combat.controller.ts @@ -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, + ) {} + + // ---------- 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 { + const character = await this.characterRepo.findOne({ + where: { userId }, + }); + if (!character) { + throw new Error('Aucun personnage trouve'); + } + return character; + } +} diff --git a/src/combat/turn/turn-combat.service.ts b/src/combat/turn/turn-combat.service.ts new file mode 100644 index 0000000..26f235f --- /dev/null +++ b/src/combat/turn/turn-combat.service.ts @@ -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(); + private cleanupTimer: ReturnType; + + constructor( + @InjectRepository(Character) + private readonly characterRepo: Repository, + @InjectRepository(CombatLog) + private readonly combatLogRepo: Repository, + 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 { + 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 { + 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 { + 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 { + 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); + } + } + } +} diff --git a/src/combat/turn/types.ts b/src/combat/turn/types.ts new file mode 100644 index 0000000..a3161f8 --- /dev/null +++ b/src/combat/turn/types.ts @@ -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; // 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; + 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 diff --git a/src/database/migrations/1743004800000-TurnCombatSystem.ts b/src/database/migrations/1743004800000-TurnCombatSystem.ts new file mode 100644 index 0000000..2a2a5be --- /dev/null +++ b/src/database/migrations/1743004800000-TurnCombatSystem.ts @@ -0,0 +1,147 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class TurnCombatSystem1743004800000 implements MigrationInterface { + name = 'TurnCombatSystem1743004800000'; + + public async up(queryRunner: QueryRunner): Promise { + // --- 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 { + 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\``); + } +} diff --git a/src/monster/monster.entity.ts b/src/monster/monster.entity.ts index ab1b074..5d45bfe 100644 --- a/src/monster/monster.entity.ts +++ b/src/monster/monster.entity.ts @@ -42,4 +42,10 @@ export class Monster { @Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' }) zone: string; + + @Column({ name: 'ai_profile', type: 'varchar', length: 20, default: 'aggressive' }) + aiProfile: string; + + @Column({ name: 'is_boss', default: false }) + isBoss: boolean; }