import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Spell } from './spell.entity'; import { PlayerSpell } from './player-spell.entity'; import { PlayerDaoPath } from './player-dao-path.entity'; import { DaoPath, SpellEffect, Buff, Debuff, CombatSession, TurnLogEntry, MANA_REGEN_PER_TURN, } from './types'; // ---------- Resultat d'un cast ---------- export interface CastResult { success: boolean; manaCost: number; events: TurnLogEntry[]; damageDealt: number; healDone: number; buffsApplied: Buff[]; debuffsApplied: Debuff[]; purgedBuffs: number; purgedDebuffs: number; } @Injectable() export class SpellSystem { constructor( @InjectRepository(Spell) private readonly spellRepo: Repository, @InjectRepository(PlayerSpell) private readonly playerSpellRepo: Repository, @InjectRepository(PlayerDaoPath) private readonly daoPathRepo: Repository, ) {} // ---------- Lecture ---------- /** Sorts debloques par le joueur */ async getUnlockedSpells(characterId: string): Promise { const playerSpells = await this.playerSpellRepo.find({ where: { characterId }, relations: ['spell'], }); return playerSpells.map((ps) => ps.spell); } /** Tous les sorts (pour affichage arbre) */ async getAllSpells(): Promise { return this.spellRepo.find({ order: { path: 'ASC', pathLevel: 'ASC' } }); } /** Progression du joueur dans les voies */ async getDaoPaths(characterId: string): Promise { return this.daoPathRepo.find({ where: { characterId } }); } // ---------- Deblocage ---------- async unlockSpell(characterId: string, spellId: string): Promise { const spell = await this.spellRepo.findOne({ where: { id: spellId } }); if (!spell) throw new BadRequestException('Sort introuvable'); // Verifier que le joueur a la voie et le niveau requis const daoPath = await this.daoPathRepo.findOne({ where: { characterId, path: spell.path }, }); if (!daoPath) { throw new BadRequestException( `Voie ${spell.path} non initiee. Choisissez votre voie du Dao.`, ); } if (daoPath.pathPoints < spell.unlockCost) { throw new BadRequestException( `Points de voie insuffisants (${daoPath.pathPoints}/${spell.unlockCost})`, ); } // Verifier pas deja debloque const existing = await this.playerSpellRepo.findOne({ where: { characterId, spellId }, }); if (existing) throw new BadRequestException('Sort deja debloque'); // Verifier que le sort precedent dans la voie est debloque (sauf niv 1) if (spell.pathLevel > 1) { const previousSpell = await this.spellRepo.findOne({ where: { path: spell.path, pathLevel: spell.pathLevel - 1 }, }); if (previousSpell) { const hasPrevious = await this.playerSpellRepo.findOne({ where: { characterId, spellId: previousSpell.id }, }); if (!hasPrevious) { throw new BadRequestException( `Debloque d'abord ${previousSpell.name} (niveau ${previousSpell.pathLevel})`, ); } } } // Depenser les points daoPath.pathPoints -= spell.unlockCost; if (spell.pathLevel > daoPath.pathLevel) { daoPath.pathLevel = spell.pathLevel; } await this.daoPathRepo.save(daoPath); const playerSpell = this.playerSpellRepo.create({ characterId, spellId }); return this.playerSpellRepo.save(playerSpell); } // ---------- Choix de voie ---------- async choosePrimaryPath(characterId: string, path: DaoPath): Promise { // Verifier qu'aucune voie primaire n'existe deja const existing = await this.daoPathRepo.findOne({ where: { characterId, isPrimary: true }, }); if (existing) { throw new BadRequestException( `Voie primaire deja choisie : ${existing.path}`, ); } // Creer les 3 voies, marquer celle choisie comme primaire const paths: PlayerDaoPath[] = []; for (const p of ['ecoute', 'resonance', 'harmonie'] as DaoPath[]) { let daoPath = await this.daoPathRepo.findOne({ where: { characterId, path: p }, }); if (!daoPath) { daoPath = this.daoPathRepo.create({ characterId, path: p, isPrimary: p === path, pathPoints: p === path ? 1 : 0, // premier point gratuit sur la voie principale pathLevel: 0, }); } else { daoPath.isPrimary = p === path; } paths.push(await this.daoPathRepo.save(daoPath)); } // Debloquer automatiquement le sort de niveau 1 de la voie choisie const starterSpell = await this.spellRepo.findOne({ where: { path, pathLevel: 1 }, }); if (starterSpell) { const alreadyUnlocked = await this.playerSpellRepo.findOne({ where: { characterId, spellId: starterSpell.id }, }); if (!alreadyUnlocked) { await this.playerSpellRepo.save( this.playerSpellRepo.create({ characterId, spellId: starterSpell.id }), ); } } return paths.find((p) => p.isPrimary)!; } // ---------- Cast en combat ---------- /** * Resout un sort pendant le combat tour par tour. * Ne modifie PAS la session directement — retourne les effets a appliquer. */ async cast( session: CombatSession, spellId: string, casterStats: { intelligence: number; force: number; hpMax: number }, ): Promise { // Verifier que le sort est debloque const playerSpell = await this.playerSpellRepo.findOne({ where: { characterId: session.characterId, spellId }, relations: ['spell'], }); if (!playerSpell) { throw new BadRequestException('Sort non debloque'); } const spell = playerSpell.spell; // Verifier mana if (session.playerMana < spell.manaCost) { throw new BadRequestException( `Mana insuffisant (${session.playerMana}/${spell.manaCost})`, ); } // Verifier cooldown const cd = session.spellCooldowns[spellId] ?? 0; if (cd > 0) { throw new BadRequestException( `Sort en cooldown (${cd} tour${cd > 1 ? 's' : ''} restant${cd > 1 ? 's' : ''})`, ); } const effects = spell.effects as SpellEffect[]; const events: TurnLogEntry[] = []; let totalDamage = 0; let totalHeal = 0; const buffsApplied: Buff[] = []; const debuffsApplied: Debuff[] = []; let purgedBuffs = 0; let purgedDebuffs = 0; for (const effect of effects) { switch (effect.type) { case 'damage': { const stat = effect.ratioStat === 'force' ? casterStats.force : casterStats.intelligence; const damage = Math.floor(stat * (effect.ratio ?? 1)); totalDamage += damage; events.push({ round: session.round, actor: session.playerName, action: spell.name, detail: effect.log .replace('{caster}', session.playerName) .replace('{target}', session.monsterName) .replace('{damage}', String(damage)), hpAfter: { player: session.playerHp, monster: Math.max(0, session.monsterHp - damage), }, }); break; } case 'heal': { // value = % hpMax, ratio = multiplicateur d'int const fromRatio = Math.floor( casterStats.intelligence * (effect.ratio ?? 0), ); const fromPercent = Math.floor( casterStats.hpMax * ((effect.value ?? 0) / 100), ); const heal = fromRatio + fromPercent; totalHeal += heal; events.push({ round: session.round, actor: session.playerName, action: spell.name, detail: effect.log .replace('{caster}', session.playerName) .replace('{target}', session.playerName) .replace('{heal}', String(heal)), hpAfter: { player: Math.min(session.playerHpMax, session.playerHp + heal), monster: session.monsterHp, }, }); break; } case 'buff': { const buff: Buff = { id: `${spell.id}-${effect.stat}-${session.round}`, name: spell.name, stat: effect.stat!, value: effect.value ?? 0, isPercent: effect.isPercent ?? true, remainingTurns: effect.duration ?? 1, sourceSpellId: spell.id, }; buffsApplied.push(buff); events.push({ round: session.round, actor: session.playerName, action: spell.name, detail: effect.log .replace('{caster}', session.playerName) .replace('{target}', session.playerName), hpAfter: { player: session.playerHp, monster: session.monsterHp }, }); break; } case 'debuff': { const debuff: Debuff = { id: `${spell.id}-${effect.stat}-${session.round}`, name: spell.name, stat: effect.stat!, value: effect.value ?? 0, isPercent: effect.isPercent ?? true, remainingTurns: effect.duration ?? 1, sourceSpellId: spell.id, }; debuffsApplied.push(debuff); events.push({ round: session.round, actor: session.playerName, action: spell.name, detail: effect.log .replace('{caster}', session.playerName) .replace('{target}', session.monsterName), hpAfter: { player: session.playerHp, monster: session.monsterHp }, }); break; } case 'purge': { if (effect.stat === 'buff') { // Purge buffs ennemis purgedBuffs += effect.value ?? 1; } else { // Purge debuffs allies purgedDebuffs += effect.value ?? 1; } events.push({ round: session.round, actor: session.playerName, action: spell.name, detail: effect.log .replace('{caster}', session.playerName) .replace('{target}', session.monsterName), hpAfter: { player: session.playerHp, monster: session.monsterHp }, }); break; } case 'special': { // Les effets speciaux sont resolus par le TurnManager (Phase C) // Ici on les signale dans le log events.push({ round: session.round, actor: session.playerName, action: spell.name, detail: effect.log .replace('{caster}', session.playerName) .replace('{target}', session.monsterName), hpAfter: { player: session.playerHp, monster: session.monsterHp }, }); break; } } } return { success: true, manaCost: spell.manaCost, events, damageDealt: totalDamage, healDone: totalHeal, buffsApplied, debuffsApplied, purgedBuffs, purgedDebuffs, }; } // ---------- Utilitaires combat ---------- /** Regen mana en debut de tour */ regenMana(currentMana: number, maxMana: number): number { return Math.min(maxMana, currentMana + MANA_REGEN_PER_TURN); } /** Tick buffs/debuffs en fin de tour — decremente et retire les expires */ tickBuffs(buffs: Buff[]): Buff[] { return buffs .map((b) => ({ ...b, remainingTurns: b.remainingTurns - 1 })) .filter((b) => b.remainingTurns > 0); } tickDebuffs(debuffs: Debuff[]): Debuff[] { return debuffs .map((d) => ({ ...d, remainingTurns: d.remainingTurns - 1 })) .filter((d) => d.remainingTurns > 0); } /** Calcule le modificateur total d'un stat depuis les buffs actifs */ getBuffModifier(buffs: Buff[], stat: string): number { return buffs .filter((b) => b.stat === stat) .reduce((acc, b) => acc + (b.isPercent ? b.value : 0), 0); } /** Verifie si un debuff specifique est actif */ hasDebuff(debuffs: Debuff[], stat: string): boolean { return debuffs.some((d) => d.stat === stat); } /** Calcule la mana max d'un personnage */ computeMaxMana(intelligence: number): number { return 50 + intelligence * 2; } }