Files
TetaRdPG/src/combat/turn/spell.system.ts
Tetardtek 9d50adf523 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.
2026-03-25 00:58:47 +01:00

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