feat: Sprint 2 — moteur de combat PvE TetaRdPG
Moteur combat stateless (POST /api/combat/start résout le combat complet). Formules GDD : Mêlée/Distance/Magie × 1.5, critique (5% + Chance×0.2%), esquive (5% + Chance×0.1%). 5 monstres seedés (Têtard Vase → Golem de Boue, level 1–9). Level up : XP → seuil atteint → level++, +5 statPoints. Persiste combat_logs (jsonb rounds). Validé : victoire, défaite, 401, 400, 404.
This commit is contained in:
126
SPRINT2.md
Normal file
126
SPRINT2.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# TetaRdPG — Brief Sprint 2
|
||||||
|
|
||||||
|
> Statut : 🔄 En cours
|
||||||
|
> Objectif : Moteur de combat PvE + retours textuels (logs)
|
||||||
|
> Stack : NestJS · PostgreSQL · TypeORM (synchronize dev)
|
||||||
|
> Prérequis : Sprint 1 livré ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Sprint 2
|
||||||
|
|
||||||
|
### ✅ In scope
|
||||||
|
|
||||||
|
- Entité `monsters` (5 monstres seedés, level 1–8)
|
||||||
|
- Moteur de combat PvE — résolution stateless côté serveur (un POST = combat complet)
|
||||||
|
- Formules GDD : Mêlée/Distance/Magie × 1.5 | Critique | Esquive
|
||||||
|
- Fin de combat : Victoire (XP + Or + +10% PV) / Défaite (20% PV + −50 endurance + perte or)
|
||||||
|
- Persistance des combats (`combat_logs`)
|
||||||
|
- Mise à jour character post-combat : hpCurrent, endurance, xp, gold, level, statPoints
|
||||||
|
- Level up basique : XP → seuil atteint → level++, +5 statPoints
|
||||||
|
- API : `GET /api/monsters`, `POST /api/combat/start`, `GET /api/combat/history`
|
||||||
|
|
||||||
|
### ❌ Out of scope
|
||||||
|
|
||||||
|
- Interactivité tour par tour (choix action par round) — Sprint 3
|
||||||
|
- Équipement / armure / arme → défense joueur = 0 Sprint 2
|
||||||
|
- Forge, artisanat, boutique
|
||||||
|
- Twitch, PvP
|
||||||
|
- Frontend React
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Décisions de design (game-designer)
|
||||||
|
|
||||||
|
| Décision | Valeur Sprint 2 | Justification |
|
||||||
|
|----------|----------------|---------------|
|
||||||
|
| Dégâts joueur | `Math.floor(stat × 1.5)` | Arme = 0 (pas d'équipement) |
|
||||||
|
| Défense joueur | 0 | Pas d'armure Sprint 2 |
|
||||||
|
| Crit | `5% + Chance × 0.2%` → dégâts ×1.5 | GDD |
|
||||||
|
| Esquive | `5% + Chance × 0.1%` → annule dégâts | GDD |
|
||||||
|
| Crit/Esquive monstres | Non (Sprint 2) | Simplification, fairness joueur |
|
||||||
|
| Défaite HP | 20% hpMax (retour auberge) | GDD "retour auberge" |
|
||||||
|
| Coût combat défaite | −10 (start) + −50 (peine) = −60 | GDD |
|
||||||
|
| Level up | `XP requise = round(100 × level^1.5)` | Formule GDD |
|
||||||
|
| Stat points / level | +5 par level franchi | GDD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monstres seedés
|
||||||
|
|
||||||
|
| Nom | Level min | Level max | HP | Attaque | Défense | Type | XP | Or (min-max) |
|
||||||
|
|-----|-----------|-----------|-----|---------|---------|------|----|-------------|
|
||||||
|
| Têtard Vase | 1 | 2 | 40 | 5 | 0 | melee | 25 | 3–8 |
|
||||||
|
| Grenouille Boueuse | 2 | 4 | 65 | 8 | 1 | melee | 45 | 6–15 |
|
||||||
|
| Serpent des Marais | 3 | 6 | 90 | 11 | 2 | ranged | 70 | 10–25 |
|
||||||
|
| Champi Vénéneux | 2 | 5 | 75 | 9 | 3 | magic | 60 | 8–20 |
|
||||||
|
| Golem de Boue | 6 | 9 | 150 | 16 | 5 | melee | 130 | 25–60 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Sprint 2
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/monsters → liste tous les monstres
|
||||||
|
POST /api/combat/start → { monsterId, attackType: 'melee'|'ranged'|'magic' }
|
||||||
|
GET /api/combat/history → combats du personnage connecté
|
||||||
|
```
|
||||||
|
|
||||||
|
### Format réponse POST /api/combat/start
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"winner": "player",
|
||||||
|
"rounds": [
|
||||||
|
{
|
||||||
|
"round": 1,
|
||||||
|
"playerDamage": 6, "playerCrit": true, "monsterDodged": false,
|
||||||
|
"monsterDamage": 5, "playerDodged": false,
|
||||||
|
"playerHp": 95, "monsterHp": 34,
|
||||||
|
"log": ["Tetard frappe le Têtard Vase pour 6 dégâts (CRITIQUE) !", "Le Têtard Vase frappe Tetard pour 5 dégâts."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Victoire en 7 tours ! +25 XP, +5 Or.",
|
||||||
|
"rewards": { "xp": 25, "gold": 5, "levelUp": false },
|
||||||
|
"character": { "level": 1, "xp": 25, "gold": 5, "hpCurrent": 75, "enduranceCurrent": 90 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture modules
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── monster/
|
||||||
|
│ ├── monster.entity.ts
|
||||||
|
│ ├── monster.module.ts
|
||||||
|
│ ├── monster.service.ts
|
||||||
|
│ └── monster.controller.ts
|
||||||
|
├── combat/
|
||||||
|
│ ├── combat.engine.ts → fonctions pures (pas de dépendances NestJS)
|
||||||
|
│ ├── combat-log.entity.ts
|
||||||
|
│ ├── combat.service.ts
|
||||||
|
│ ├── combat.controller.ts
|
||||||
|
│ ├── combat.module.ts
|
||||||
|
│ └── dto/start-combat.dto.ts
|
||||||
|
└── database/
|
||||||
|
└── monsters-seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères de validation integrator
|
||||||
|
|
||||||
|
- [ ] `GET /api/monsters` → liste 5 monstres
|
||||||
|
- [ ] `POST /api/combat/start` → combat résolu, log retourné
|
||||||
|
- [ ] Personnage level 1 peut vaincre Têtard Vase
|
||||||
|
- [ ] XP et or crédités après victoire
|
||||||
|
- [ ] `hpCurrent` mis à jour en DB après combat
|
||||||
|
- [ ] Endurance déduite (−10) après combat
|
||||||
|
- [ ] Défaite : `hpCurrent` = 20% hpMax, endurance −60 total
|
||||||
|
- [ ] `GET /api/combat/history` → historique retourné
|
||||||
|
- [ ] Sans cookie → 401
|
||||||
|
- [ ] Endurance insuffisante (< 10) → 400
|
||||||
|
- [ ] Monster inexistant → 404
|
||||||
|
- [ ] Level up déclenché si XP seuil atteint
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
||||||
|
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
|
||||||
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CharacterModule } from './character/character.module';
|
import { CharacterModule } from './character/character.module';
|
||||||
|
import { MonsterModule } from './monster/monster.module';
|
||||||
|
import { CombatModule } from './combat/combat.module';
|
||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -31,6 +33,8 @@ import { HealthController } from './common/health.controller';
|
|||||||
|
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CharacterModule,
|
CharacterModule,
|
||||||
|
MonsterModule,
|
||||||
|
CombatModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ export class Character {
|
|||||||
@Column({ name: 'endurance_max', default: 100 })
|
@Column({ name: 'endurance_max', default: 100 })
|
||||||
enduranceMax: number;
|
enduranceMax: number;
|
||||||
|
|
||||||
|
// Points de stats disponibles (non distribués) — +5 par level up
|
||||||
|
@Column({ name: 'stat_points', default: 0 })
|
||||||
|
statPoints: number;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
52
src/combat/combat-log.entity.ts
Normal file
52
src/combat/combat-log.entity.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { Monster } from '../monster/monster.entity';
|
||||||
|
|
||||||
|
@Entity('combat_logs')
|
||||||
|
export class CombatLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id' })
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character)
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character: Character;
|
||||||
|
|
||||||
|
@Column({ name: 'monster_id' })
|
||||||
|
monsterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Monster)
|
||||||
|
@JoinColumn({ name: 'monster_id' })
|
||||||
|
monster: Monster;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10 })
|
||||||
|
winner: 'player' | 'monster';
|
||||||
|
|
||||||
|
@Column({ name: 'total_rounds' })
|
||||||
|
totalRounds: number;
|
||||||
|
|
||||||
|
// Stocker les rounds en JSON — lecture replay
|
||||||
|
@Column({ name: 'rounds_data', type: 'jsonb' })
|
||||||
|
roundsData: object;
|
||||||
|
|
||||||
|
@Column({ name: 'xp_earned', default: 0 })
|
||||||
|
xpEarned: number;
|
||||||
|
|
||||||
|
@Column({ name: 'gold_earned', default: 0 })
|
||||||
|
goldEarned: number;
|
||||||
|
|
||||||
|
@Column({ name: 'level_up', default: false })
|
||||||
|
levelUp: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
26
src/combat/combat.controller.ts
Normal file
26
src/combat/combat.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Post, Get, Body, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { CombatService } from './combat.service';
|
||||||
|
import { StartCombatDto } from './dto/start-combat.dto';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Controller('combat')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class CombatController {
|
||||||
|
constructor(private readonly combatService: CombatService) {}
|
||||||
|
|
||||||
|
@Post('start')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
startCombat(
|
||||||
|
@Body() dto: StartCombatDto,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
return this.combatService.startCombat(dto, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('history')
|
||||||
|
getHistory(@Req() req: Request & { user: User }) {
|
||||||
|
return this.combatService.getHistory(req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/combat/combat.engine.ts
Normal file
202
src/combat/combat.engine.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { AttackType } from '../monster/monster.entity';
|
||||||
|
|
||||||
|
// ---------- Types ----------
|
||||||
|
|
||||||
|
export interface CombatantStats {
|
||||||
|
name: string;
|
||||||
|
hpCurrent: number;
|
||||||
|
hpMax: number;
|
||||||
|
force: number;
|
||||||
|
agilite: number;
|
||||||
|
intelligence: number;
|
||||||
|
chance: number;
|
||||||
|
attack: number; // arme (0 Sprint 2) pour joueur, flat pour monstre
|
||||||
|
defense: number; // 0 joueur Sprint 2
|
||||||
|
attackType: AttackType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttackResult {
|
||||||
|
damage: number;
|
||||||
|
isCrit: boolean;
|
||||||
|
isDodged: boolean;
|
||||||
|
log: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundLog {
|
||||||
|
round: number;
|
||||||
|
playerAttack: AttackResult;
|
||||||
|
monsterAttack: AttackResult;
|
||||||
|
playerHp: number;
|
||||||
|
monsterHp: number;
|
||||||
|
log: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatResult {
|
||||||
|
winner: 'player' | 'monster';
|
||||||
|
rounds: RoundLog[];
|
||||||
|
xpEarned: number;
|
||||||
|
goldEarned: number;
|
||||||
|
totalRounds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Formules (déterministes pour les tests, mais utilisent Math.random) ----------
|
||||||
|
|
||||||
|
const MAX_ROUNDS = 30; // sécurité anti-boucle infinie
|
||||||
|
|
||||||
|
function statForAttackType(stats: CombatantStats): number {
|
||||||
|
switch (stats.attackType) {
|
||||||
|
case 'melee': return stats.force;
|
||||||
|
case 'ranged': return stats.agilite;
|
||||||
|
case 'magic': return stats.intelligence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number {
|
||||||
|
const stat = statForAttackType(player);
|
||||||
|
const raw = player.attack + Math.floor(stat * 1.5);
|
||||||
|
return Math.max(1, raw - monsterDefense);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcMonsterDamage(monster: CombatantStats, playerDefense: number): number {
|
||||||
|
return Math.max(1, monster.attack - playerDefense);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollCrit(chance: number): boolean {
|
||||||
|
const rate = 0.05 + chance * 0.002;
|
||||||
|
return Math.random() < rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rollDodge(chance: number): boolean {
|
||||||
|
const rate = 0.05 + chance * 0.001;
|
||||||
|
return Math.random() < rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePlayerAttack(
|
||||||
|
player: CombatantStats,
|
||||||
|
monster: CombatantStats,
|
||||||
|
): AttackResult {
|
||||||
|
// Les monstres ne dodgent pas en Sprint 2
|
||||||
|
const baseDamage = calcPlayerDamage(player, monster.defense);
|
||||||
|
const isCrit = rollCrit(player.chance);
|
||||||
|
const damage = isCrit ? Math.floor(baseDamage * 1.5) : baseDamage;
|
||||||
|
|
||||||
|
const critText = isCrit ? ' (CRITIQUE !)' : '';
|
||||||
|
const log = `${player.name} attaque ${monster.name} pour ${damage} dégâts${critText}`;
|
||||||
|
|
||||||
|
return { damage, isCrit, isDodged: false, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMonsterAttack(
|
||||||
|
monster: CombatantStats,
|
||||||
|
player: CombatantStats,
|
||||||
|
): AttackResult {
|
||||||
|
const isDodged = rollDodge(player.chance);
|
||||||
|
if (isDodged) {
|
||||||
|
return {
|
||||||
|
damage: 0,
|
||||||
|
isCrit: false,
|
||||||
|
isDodged: true,
|
||||||
|
log: `${player.name} esquive l'attaque de ${monster.name} !`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const damage = calcMonsterDamage(monster, player.defense);
|
||||||
|
return {
|
||||||
|
damage,
|
||||||
|
isCrit: false,
|
||||||
|
isDodged: false,
|
||||||
|
log: `${monster.name} attaque ${player.name} pour ${damage} dégâts.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Résolution complète ----------
|
||||||
|
|
||||||
|
export function resolveCombat(
|
||||||
|
player: CombatantStats,
|
||||||
|
monster: CombatantStats,
|
||||||
|
xpReward: number,
|
||||||
|
goldMin: number,
|
||||||
|
goldMax: number,
|
||||||
|
): CombatResult {
|
||||||
|
let playerHp = player.hpCurrent;
|
||||||
|
let monsterHp = monster.hpCurrent;
|
||||||
|
const rounds: RoundLog[] = [];
|
||||||
|
|
||||||
|
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
||||||
|
// -- Tour joueur --
|
||||||
|
const playerAttack = resolvePlayerAttack(player, monster);
|
||||||
|
monsterHp = Math.max(0, monsterHp - playerAttack.damage);
|
||||||
|
|
||||||
|
// -- Tour monstre (seulement si encore vivant) --
|
||||||
|
let monsterAttack: AttackResult;
|
||||||
|
if (monsterHp > 0) {
|
||||||
|
monsterAttack = resolveMonsterAttack(monster, player);
|
||||||
|
playerHp = Math.max(0, playerHp - monsterAttack.damage);
|
||||||
|
} else {
|
||||||
|
monsterAttack = { damage: 0, isCrit: false, isDodged: false, log: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
rounds.push({
|
||||||
|
round,
|
||||||
|
playerAttack,
|
||||||
|
monsterAttack,
|
||||||
|
playerHp,
|
||||||
|
monsterHp,
|
||||||
|
log: [
|
||||||
|
playerAttack.log,
|
||||||
|
...(monsterAttack.log ? [monsterAttack.log] : []),
|
||||||
|
`HP — ${player.name}: ${playerHp}/${player.hpMax} | ${monster.name}: ${monsterHp}/${monster.hpCurrent}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (monsterHp <= 0) {
|
||||||
|
const goldEarned = goldMin + Math.floor(Math.random() * (goldMax - goldMin + 1));
|
||||||
|
return { winner: 'player', rounds, xpEarned: xpReward, goldEarned, totalRounds: round };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerHp <= 0) {
|
||||||
|
return { winner: 'monster', rounds, xpEarned: 0, goldEarned: 0, totalRounds: round };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout (ne devrait pas arriver avec des valeurs équilibrées)
|
||||||
|
return { winner: 'monster', rounds, xpEarned: 0, goldEarned: 0, totalRounds: MAX_ROUNDS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Level up ----------
|
||||||
|
|
||||||
|
export function xpRequiredForLevel(level: number): number {
|
||||||
|
return Math.round(100 * Math.pow(level, 1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelUpResult {
|
||||||
|
newLevel: number;
|
||||||
|
newXp: number;
|
||||||
|
statPointsGained: number;
|
||||||
|
levelsGained: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyXpGain(currentLevel: number, currentXp: number, xpEarned: number): LevelUpResult {
|
||||||
|
let level = currentLevel;
|
||||||
|
let xp = currentXp + xpEarned;
|
||||||
|
let statPointsGained = 0;
|
||||||
|
|
||||||
|
// Chaîne de level up
|
||||||
|
while (level < 100) {
|
||||||
|
const required = xpRequiredForLevel(level + 1);
|
||||||
|
if (xp >= required) {
|
||||||
|
xp -= required;
|
||||||
|
level++;
|
||||||
|
statPointsGained += 5;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newLevel: level,
|
||||||
|
newXp: xp,
|
||||||
|
statPointsGained,
|
||||||
|
levelsGained: level - currentLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/combat/combat.module.ts
Normal file
19
src/combat/combat.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { CombatLog } from './combat-log.entity';
|
||||||
|
import { CombatService } from './combat.service';
|
||||||
|
import { CombatController } from './combat.controller';
|
||||||
|
import { MonsterModule } from '../monster/monster.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Character, CombatLog]),
|
||||||
|
MonsterModule,
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
|
controllers: [CombatController],
|
||||||
|
providers: [CombatService],
|
||||||
|
})
|
||||||
|
export class CombatModule {}
|
||||||
195
src/combat/combat.service.ts
Normal file
195
src/combat/combat.service.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { Monster } from '../monster/monster.entity';
|
||||||
|
import { MonsterService } from '../monster/monster.service';
|
||||||
|
import { CombatLog } from './combat-log.entity';
|
||||||
|
import { StartCombatDto } from './dto/start-combat.dto';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
import {
|
||||||
|
resolveCombat,
|
||||||
|
applyXpGain,
|
||||||
|
CombatantStats,
|
||||||
|
} from './combat.engine';
|
||||||
|
|
||||||
|
const COMBAT_ENDURANCE_COST = 10;
|
||||||
|
const DEFEAT_ENDURANCE_PENALTY = 50;
|
||||||
|
const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite
|
||||||
|
const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire
|
||||||
|
const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CombatService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
@InjectRepository(CombatLog)
|
||||||
|
private readonly combatLogRepository: Repository<CombatLog>,
|
||||||
|
private readonly monsterService: MonsterService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async startCombat(dto: StartCombatDto, user: User) {
|
||||||
|
// Charger le personnage
|
||||||
|
const character = await this.characterRepository.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
if (!character) throw new BadRequestException('Aucun personnage trouvé');
|
||||||
|
|
||||||
|
// Charger le monstre
|
||||||
|
const monster = await this.monsterService.findOne(dto.monsterId);
|
||||||
|
|
||||||
|
// Calculer l'endurance actuelle (lazy pattern)
|
||||||
|
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||||
|
const recharge = Math.floor(elapsedMinutes / 6);
|
||||||
|
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||||
|
|
||||||
|
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character.hpCurrent <= 0) {
|
||||||
|
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire les stats des combattants
|
||||||
|
const playerStats: CombatantStats = {
|
||||||
|
name: character.name,
|
||||||
|
hpCurrent: character.hpCurrent,
|
||||||
|
hpMax: character.hpMax,
|
||||||
|
force: character.force,
|
||||||
|
agilite: character.agilite,
|
||||||
|
intelligence: character.intelligence,
|
||||||
|
chance: character.chance,
|
||||||
|
attack: 0, // pas d'arme Sprint 2
|
||||||
|
defense: 0, // pas d'armure Sprint 2
|
||||||
|
attackType: dto.attackType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const monsterStats: CombatantStats = {
|
||||||
|
name: monster.name,
|
||||||
|
hpCurrent: monster.hp,
|
||||||
|
hpMax: monster.hp,
|
||||||
|
force: 0,
|
||||||
|
agilite: 0,
|
||||||
|
intelligence: 0,
|
||||||
|
chance: 0, // pas de crit/esquive pour les monstres Sprint 2
|
||||||
|
attack: monster.attack,
|
||||||
|
defense: monster.defense,
|
||||||
|
attackType: monster.attackType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Résolution combat
|
||||||
|
const result = resolveCombat(
|
||||||
|
playerStats,
|
||||||
|
monsterStats,
|
||||||
|
monster.xpReward,
|
||||||
|
monster.goldMin,
|
||||||
|
monster.goldMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Appliquer les effets post-combat sur le personnage
|
||||||
|
let newHp = character.hpCurrent;
|
||||||
|
let newEnduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST;
|
||||||
|
let goldLost = 0;
|
||||||
|
let levelUpData = { levelsGained: 0, statPointsGained: 0, newLevel: character.level, newXp: character.xp };
|
||||||
|
|
||||||
|
if (result.winner === 'player') {
|
||||||
|
// Victoire : XP + Or + récup 10% PV
|
||||||
|
levelUpData = applyXpGain(character.level, character.xp, result.xpEarned);
|
||||||
|
character.xp = levelUpData.newXp;
|
||||||
|
character.level = levelUpData.newLevel;
|
||||||
|
character.statPoints = (character.statPoints ?? 0) + levelUpData.statPointsGained;
|
||||||
|
character.gold += result.goldEarned;
|
||||||
|
newHp = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO));
|
||||||
|
} else {
|
||||||
|
// Défaite : retour auberge + pénalités
|
||||||
|
newEnduranceSaved = Math.max(0, newEnduranceSaved - DEFEAT_ENDURANCE_PENALTY);
|
||||||
|
newHp = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO));
|
||||||
|
goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO);
|
||||||
|
character.gold = Math.max(0, character.gold - goldLost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sauvegarder l'endurance (lazy reset)
|
||||||
|
character.hpCurrent = newHp;
|
||||||
|
character.enduranceSaved = newEnduranceSaved;
|
||||||
|
character.lastEnduranceTs = new Date();
|
||||||
|
await this.characterRepository.save(character);
|
||||||
|
|
||||||
|
// Persister le log
|
||||||
|
const combatLog = this.combatLogRepository.create({
|
||||||
|
characterId: character.id,
|
||||||
|
monsterId: monster.id,
|
||||||
|
winner: result.winner,
|
||||||
|
totalRounds: result.totalRounds,
|
||||||
|
roundsData: result.rounds,
|
||||||
|
xpEarned: result.xpEarned,
|
||||||
|
goldEarned: result.goldEarned,
|
||||||
|
levelUp: levelUpData.levelsGained > 0,
|
||||||
|
});
|
||||||
|
await this.combatLogRepository.save(combatLog);
|
||||||
|
|
||||||
|
// Construire la réponse
|
||||||
|
const summaryParts: string[] = [];
|
||||||
|
if (result.winner === 'player') {
|
||||||
|
summaryParts.push(`Victoire en ${result.totalRounds} tours !`);
|
||||||
|
summaryParts.push(`+${result.xpEarned} XP, +${result.goldEarned} Or.`);
|
||||||
|
if (levelUpData.levelsGained > 0) {
|
||||||
|
summaryParts.push(`LEVEL UP ! Niveau ${levelUpData.newLevel} atteint. +${levelUpData.statPointsGained} points de stats.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summaryParts.push(`Défaite au tour ${result.totalRounds}. Retour à l'auberge.`);
|
||||||
|
if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
winner: result.winner,
|
||||||
|
rounds: result.rounds,
|
||||||
|
summary: summaryParts.join(' '),
|
||||||
|
rewards: {
|
||||||
|
xp: result.xpEarned,
|
||||||
|
gold: result.goldEarned,
|
||||||
|
goldLost,
|
||||||
|
levelUp: levelUpData.levelsGained > 0,
|
||||||
|
newLevel: levelUpData.newLevel,
|
||||||
|
statPointsGained: levelUpData.statPointsGained,
|
||||||
|
},
|
||||||
|
character: {
|
||||||
|
level: character.level,
|
||||||
|
xp: character.xp,
|
||||||
|
gold: character.gold,
|
||||||
|
hpCurrent: character.hpCurrent,
|
||||||
|
hpMax: character.hpMax,
|
||||||
|
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat
|
||||||
|
enduranceMax: character.enduranceMax,
|
||||||
|
statPoints: character.statPoints ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(user: User) {
|
||||||
|
const character = await this.characterRepository.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
if (!character) throw new BadRequestException('Aucun personnage trouvé');
|
||||||
|
|
||||||
|
return this.combatLogRepository.find({
|
||||||
|
where: { characterId: character.id },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 20,
|
||||||
|
relations: ['monster'],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
winner: true,
|
||||||
|
totalRounds: true,
|
||||||
|
xpEarned: true,
|
||||||
|
goldEarned: true,
|
||||||
|
levelUp: true,
|
||||||
|
createdAt: true,
|
||||||
|
monster: { id: true, name: true, minLevel: true, maxLevel: true } as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/combat/dto/start-combat.dto.ts
Normal file
10
src/combat/dto/start-combat.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsUUID, IsIn } from 'class-validator';
|
||||||
|
import { AttackType } from '../../monster/monster.entity';
|
||||||
|
|
||||||
|
export class StartCombatDto {
|
||||||
|
@IsUUID()
|
||||||
|
monsterId: string;
|
||||||
|
|
||||||
|
@IsIn(['melee', 'ranged', 'magic'])
|
||||||
|
attackType: AttackType;
|
||||||
|
}
|
||||||
78
src/database/monsters-seed.ts
Normal file
78
src/database/monsters-seed.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Monster } from '../monster/monster.entity';
|
||||||
|
|
||||||
|
const dataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
|
||||||
|
entities: [Monster],
|
||||||
|
synchronize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MONSTERS = [
|
||||||
|
{
|
||||||
|
name: 'Têtard Vase',
|
||||||
|
minLevel: 1, maxLevel: 2,
|
||||||
|
hp: 40, attack: 5, defense: 0,
|
||||||
|
attackType: 'melee' as const,
|
||||||
|
xpReward: 25, goldMin: 3, goldMax: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Grenouille Boueuse',
|
||||||
|
minLevel: 2, maxLevel: 4,
|
||||||
|
hp: 65, attack: 8, defense: 1,
|
||||||
|
attackType: 'melee' as const,
|
||||||
|
xpReward: 45, goldMin: 6, goldMax: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Serpent des Marais',
|
||||||
|
minLevel: 3, maxLevel: 6,
|
||||||
|
hp: 90, attack: 11, defense: 2,
|
||||||
|
attackType: 'ranged' as const,
|
||||||
|
xpReward: 70, goldMin: 10, goldMax: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Champi Vénéneux',
|
||||||
|
minLevel: 2, maxLevel: 5,
|
||||||
|
hp: 75, attack: 9, defense: 3,
|
||||||
|
attackType: 'magic' as const,
|
||||||
|
xpReward: 60, goldMin: 8, goldMax: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Golem de Boue',
|
||||||
|
minLevel: 6, maxLevel: 9,
|
||||||
|
hp: 150, attack: 16, defense: 5,
|
||||||
|
attackType: 'melee' as const,
|
||||||
|
xpReward: 130, goldMin: 25, goldMax: 60,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
await dataSource.initialize();
|
||||||
|
console.log('DB connectée');
|
||||||
|
|
||||||
|
const repo = dataSource.getRepository(Monster);
|
||||||
|
const existing = await repo.count();
|
||||||
|
|
||||||
|
if (existing >= MONSTERS.length) {
|
||||||
|
console.log('Monstres déjà seedés — skip');
|
||||||
|
await dataSource.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const data of MONSTERS) {
|
||||||
|
const exists = await repo.findOne({ where: { name: data.name } });
|
||||||
|
if (!exists) {
|
||||||
|
await repo.save(repo.create(data));
|
||||||
|
console.log(`✅ ${data.name} seedé`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Seed monstres terminé');
|
||||||
|
await dataSource.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch((err) => {
|
||||||
|
console.error('Seed monstres échoué :', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
14
src/monster/monster.controller.ts
Normal file
14
src/monster/monster.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { MonsterService } from './monster.service';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
|
||||||
|
@Controller('monsters')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class MonsterController {
|
||||||
|
constructor(private readonly monsterService: MonsterService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.monsterService.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/monster/monster.entity.ts
Normal file
39
src/monster/monster.entity.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
|
export type AttackType = 'melee' | 'ranged' | 'magic';
|
||||||
|
|
||||||
|
@Entity('monsters')
|
||||||
|
export class Monster {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'min_level' })
|
||||||
|
minLevel: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_level' })
|
||||||
|
maxLevel: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
hp: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
attack: number;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
defense: number;
|
||||||
|
|
||||||
|
@Column({ name: 'attack_type', type: 'varchar', length: 20 })
|
||||||
|
attackType: AttackType;
|
||||||
|
|
||||||
|
@Column({ name: 'xp_reward' })
|
||||||
|
xpReward: number;
|
||||||
|
|
||||||
|
@Column({ name: 'gold_min' })
|
||||||
|
goldMin: number;
|
||||||
|
|
||||||
|
@Column({ name: 'gold_max' })
|
||||||
|
goldMax: number;
|
||||||
|
}
|
||||||
14
src/monster/monster.module.ts
Normal file
14
src/monster/monster.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Monster } from './monster.entity';
|
||||||
|
import { MonsterService } from './monster.service';
|
||||||
|
import { MonsterController } from './monster.controller';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Monster]), AuthModule],
|
||||||
|
controllers: [MonsterController],
|
||||||
|
providers: [MonsterService],
|
||||||
|
exports: [MonsterService, TypeOrmModule],
|
||||||
|
})
|
||||||
|
export class MonsterModule {}
|
||||||
22
src/monster/monster.service.ts
Normal file
22
src/monster/monster.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Monster } from './monster.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MonsterService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Monster)
|
||||||
|
private readonly monsterRepository: Repository<Monster>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findAll(): Promise<Monster[]> {
|
||||||
|
return this.monsterRepository.find({ order: { minLevel: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<Monster> {
|
||||||
|
const monster = await this.monsterRepository.findOne({ where: { id } });
|
||||||
|
if (!monster) throw new NotFoundException(`Monstre introuvable`);
|
||||||
|
return monster;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user