fix: cooldown serveur 2s/8s + loot dans transaction (élimine deadlock)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s

This commit is contained in:
2026-03-24 21:04:11 +01:00
parent 909b8da77f
commit 74938dd35f

View File

@@ -1,6 +1,7 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common'; import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { CharacterMaterial } from '../material/character-material.entity';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Monster } from '../monster/monster.entity'; import { Monster } from '../monster/monster.entity';
@@ -54,8 +55,25 @@ const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite
const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire
const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite
/** Ajouter un matériau dans la transaction courante (pas de connexion séparée). */
async function addMaterialInTx(manager: EntityManager, characterId: string, materialId: string, quantity: number) {
const repo = manager.getRepository(CharacterMaterial);
let entry = await repo.findOne({ where: { characterId, materialId } });
if (entry) {
entry.quantity += quantity;
} else {
entry = repo.create({ characterId, materialId, quantity });
}
await repo.save(entry);
}
const COOLDOWN_SINGLE_MS = 2_000;
const COOLDOWN_MULTI_MS = 8_000;
@Injectable() @Injectable()
export class CombatService { export class CombatService {
private readonly cooldowns = new Map<string, number>(); // userId → timestamp
constructor( constructor(
@InjectRepository(Character) @InjectRepository(Character)
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
@@ -69,7 +87,23 @@ export class CombatService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
) {} ) {}
private checkCooldown(userId: string): void {
const lastCombat = this.cooldowns.get(userId) ?? 0;
const remaining = lastCombat - Date.now();
if (remaining > 0) {
throw new BadRequestException(
`Cooldown actif — attendez ${Math.ceil(remaining / 1000)}s`,
);
}
}
private setCooldown(userId: string, durationMs: number): void {
this.cooldowns.set(userId, Date.now() + durationMs);
}
async startCombat(dto: StartCombatDto, user: User) { async startCombat(dto: StartCombatDto, user: User) {
this.checkCooldown(user.id);
// Charger le monstre (hors transaction — lecture seule) // Charger le monstre (hors transaction — lecture seule)
const monster = await this.monsterService.findOne(dto.monsterId); const monster = await this.monsterService.findOne(dto.monsterId);
@@ -202,7 +236,7 @@ export class CombatService {
if (result.winner === 'player' && monster.dropMaterialId) { if (result.winner === 'player' && monster.dropMaterialId) {
const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel); const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel);
if (Math.random() < dropRate) { if (Math.random() < dropRate) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, dropQty); await addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty);
lootMaterial = { name: 'matériau', quantity: dropQty }; lootMaterial = { name: 'matériau', quantity: dropQty };
lootedMaterialId = monster.dropMaterialId; lootedMaterialId = monster.dropMaterialId;
} }
@@ -284,10 +318,12 @@ export class CombatService {
} }
} }
this.setCooldown(user.id, COOLDOWN_SINGLE_MS);
return txResult.response; return txResult.response;
} }
async startMultiCombat(dto: StartCombatDto, user: User, count: number) { async startMultiCombat(dto: StartCombatDto, user: User, count: number) {
this.checkCooldown(user.id);
const monster = await this.monsterService.findOne(dto.monsterId); const monster = await this.monsterService.findOne(dto.monsterId);
const txResult = await this.dataSource.transaction(async (manager) => { const txResult = await this.dataSource.transaction(async (manager) => {
@@ -368,7 +404,7 @@ export class CombatService {
if (monster.dropMaterialId) { if (monster.dropMaterialId) {
const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel); const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel);
if (Math.random() < dropRate) { if (Math.random() < dropRate) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, dropQty); await addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty);
totals.loot.push({ name: 'matériau', quantity: dropQty }); totals.loot.push({ name: 'matériau', quantity: dropQty });
lootedMaterialIds.push(monster.dropMaterialId); lootedMaterialIds.push(monster.dropMaterialId);
} }
@@ -428,6 +464,7 @@ export class CombatService {
} }
} }
this.setCooldown(user.id, COOLDOWN_MULTI_MS);
return { return {
mode: 'multi', mode: 'multi',
count: txResult.combatsDone, count: txResult.combatsDone,