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.
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|