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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user