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