feat(sprint5): audit fixes — transactions, indexes, stat distribution, rest, forge cost
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
P0 — Race conditions fixées avec pessimistic_write transactions : combat (double-spend endurance), forge (double upgrade), craft (consumeMaterials atomique), equip (item swap). Forge : coût or (50-1000) + endurance (15) ajouté. Combat : item stat bonuses (force/agilite/intelligence/chance) appliqués. P1 — Features manquantes : POST /api/characters/stats — distribution stat points (avec lock). POST /api/characters/rest — repos auberge (+50% HP, -20 endurance). Vitalité : +10 HP max par point distribué. P2 — Indexes DB ajoutés : character_id sur character_items, character_materials, combat_logs, craft_jobs, player_achievements, community_contributions. Composite (characterId, materialId) sur character_materials. period sur hall_of_fame. achievement_id sur player_achievements. P3 — Cleanup : @nestjs/jwt et pg retirés de package.json.
This commit is contained in:
@@ -1,13 +1,24 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CharacterItem } from '../item/character-item.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
const MAX_FORGE_LEVEL = 5;
|
||||
const FORGE_BONUS_PER_LEVEL = 2; // +2 attack (weapon) ou +2 defense (armor) par niveau affiché
|
||||
const FORGE_BONUS_PER_LEVEL = 2;
|
||||
|
||||
// Coût en or par niveau cible
|
||||
const FORGE_GOLD_COST: Record<number, number> = {
|
||||
1: 50,
|
||||
2: 100,
|
||||
3: 250,
|
||||
4: 500,
|
||||
5: 1000,
|
||||
};
|
||||
|
||||
const FORGE_ENDURANCE_COST = 15;
|
||||
|
||||
// Risque d'échec par niveau cible (GDD exact)
|
||||
const FORGE_FAIL_CHANCE: Record<number, number> = {
|
||||
@@ -26,57 +37,98 @@ export class ForgeService {
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async upgradeItem(charItemId: string, user: User) {
|
||||
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
|
||||
if (!char) throw new BadRequestException('Aucun personnage trouvé');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// Lock le personnage
|
||||
const char = await manager
|
||||
.getRepository(Character)
|
||||
.createQueryBuilder('c')
|
||||
.setLock('pessimistic_write')
|
||||
.where('c.user_id = :userId', { userId: user.id })
|
||||
.getOne();
|
||||
if (!char) throw new BadRequestException('Aucun personnage trouvé');
|
||||
|
||||
const charItem = await this.charItemRepository.findOne({
|
||||
where: { id: charItemId, characterId: char.id },
|
||||
});
|
||||
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
||||
if (charItem.forgeLevel >= MAX_FORGE_LEVEL) {
|
||||
throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`);
|
||||
}
|
||||
// Lock l'item
|
||||
const charItem = await manager
|
||||
.getRepository(CharacterItem)
|
||||
.createQueryBuilder('ci')
|
||||
.setLock('pessimistic_write')
|
||||
.leftJoinAndSelect('ci.item', 'item')
|
||||
.where('ci.id = :id', { id: charItemId })
|
||||
.andWhere('ci.character_id = :cid', { cid: char.id })
|
||||
.getOne();
|
||||
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
||||
if (charItem.forgeLevel >= MAX_FORGE_LEVEL) {
|
||||
throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`);
|
||||
}
|
||||
|
||||
const targetLevel = charItem.forgeLevel + 1;
|
||||
const failChance = FORGE_FAIL_CHANCE[targetLevel] ?? 0;
|
||||
const success = Math.random() >= failChance;
|
||||
const targetLevel = charItem.forgeLevel + 1;
|
||||
const goldCost = FORGE_GOLD_COST[targetLevel] ?? 0;
|
||||
|
||||
if (success) {
|
||||
charItem.forgeLevel = targetLevel;
|
||||
await this.charItemRepository.save(charItem);
|
||||
// Vérifier endurance
|
||||
const elapsedMinutes = (Date.now() - char.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / 6);
|
||||
const enduranceCurrent = Math.min(char.enduranceSaved + recharge, char.enduranceMax);
|
||||
|
||||
// Emit achievement & community events
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: char.id,
|
||||
type: 'forge_upgrades',
|
||||
increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: char.id,
|
||||
type: 'total_forge_upgrades',
|
||||
increment: 1,
|
||||
});
|
||||
if (enduranceCurrent < FORGE_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${FORGE_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier or
|
||||
if (char.gold < goldCost) {
|
||||
throw new BadRequestException(
|
||||
`Or insuffisant (${char.gold}/${goldCost} requis)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Déduire les coûts (même en cas d'échec)
|
||||
char.gold -= goldCost;
|
||||
char.enduranceSaved = enduranceCurrent - FORGE_ENDURANCE_COST;
|
||||
char.lastEnduranceTs = new Date();
|
||||
|
||||
const failChance = FORGE_FAIL_CHANCE[targetLevel] ?? 0;
|
||||
const success = Math.random() >= failChance;
|
||||
|
||||
if (success) {
|
||||
charItem.forgeLevel = targetLevel;
|
||||
await manager.save(charItem);
|
||||
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: char.id, type: 'forge_upgrades', increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: char.id, type: 'total_forge_upgrades', increment: 1,
|
||||
});
|
||||
}
|
||||
|
||||
await manager.save(char);
|
||||
|
||||
const statLabel = charItem.item.type === 'weapon'
|
||||
? `+${FORGE_BONUS_PER_LEVEL} ATK`
|
||||
: `+${FORGE_BONUS_PER_LEVEL} DEF`;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
forgeLevel: charItem.forgeLevel,
|
||||
item: charItem.item.name,
|
||||
goldSpent: goldCost,
|
||||
message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}). -${goldCost} Or.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
success: false,
|
||||
forgeLevel: charItem.forgeLevel,
|
||||
item: charItem.item.name,
|
||||
message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}).`,
|
||||
goldSpent: goldCost,
|
||||
message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}]. -${goldCost} Or perdus.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
forgeLevel: charItem.forgeLevel,
|
||||
item: charItem.item.name,
|
||||
message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}].`,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user