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

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

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

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

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

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

View File

@@ -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() {
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/quests" element={<ProtectedLayout><QuestPage /></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="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />

View File

@@ -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<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
export const itemApi = {
catalogue: () => api.get<Item[]>('/items'),

View File

@@ -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<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 interface Item {

View File

@@ -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 (
<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' }}>
{/* Choix monstre par zone */}

View File

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

View File

@@ -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;

View File

@@ -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 {}

View 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;
}

View File

@@ -0,0 +1,7 @@
import { IsIn } from 'class-validator';
import { DaoPath } from '../types';
export class ChooseDaoPathDto {
@IsIn(['ecoute', 'resonance', 'harmonie'])
path: DaoPath;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class UnlockSpellDto {
@IsUUID()
spellId: string;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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
View 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

View 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\``);
}
}

View File

@@ -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;
}