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:
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Achievement } from './achievement.entity';
|
||||
@@ -16,6 +17,7 @@ export class PlayerAchievement {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
@@ -23,6 +25,7 @@ export class PlayerAchievement {
|
||||
character: Character;
|
||||
|
||||
@Column({ name: 'achievement_id' })
|
||||
@Index()
|
||||
achievementId: string;
|
||||
|
||||
@ManyToOne(() => Achievement)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Request } from 'express';
|
||||
import { CharacterService } from './character.service';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { DistributeStatsDto } from './dto/distribute-stats.dto';
|
||||
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@@ -37,4 +38,19 @@ export class CharacterController {
|
||||
getEndurance(@Req() req: Request & { user: User }) {
|
||||
return this.characterService.getEndurance(req.user);
|
||||
}
|
||||
|
||||
@Post('stats')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
distributeStats(
|
||||
@Body() dto: DistributeStatsDto,
|
||||
@Req() req: Request & { user: User },
|
||||
) {
|
||||
return this.characterService.distributeStats(dto, req.user);
|
||||
}
|
||||
|
||||
@Post('rest')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
rest(@Req() req: Request & { user: User }) {
|
||||
return this.characterService.rest(req.user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { DistributeStatsDto } from './dto/distribute-stats.dto';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer
|
||||
const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure
|
||||
const REST_ENDURANCE_COST = 20;
|
||||
const REST_HP_REGEN_RATIO = 0.5; // +50% hpMax
|
||||
|
||||
@Injectable()
|
||||
export class CharacterService {
|
||||
@@ -21,6 +24,7 @@ export class CharacterService {
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
@InjectRepository(LevelThreshold)
|
||||
private readonly levelThresholdRepository: Repository<LevelThreshold>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// Pattern lazy calculation — pas de timer actif
|
||||
@@ -94,4 +98,106 @@ export class CharacterService {
|
||||
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
async distributeStats(dto: DistributeStatsDto, user: User) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const character = await manager
|
||||
.getRepository(Character)
|
||||
.createQueryBuilder('c')
|
||||
.setLock('pessimistic_write')
|
||||
.where('c.user_id = :userId', { userId: user.id })
|
||||
.getOne();
|
||||
|
||||
if (!character) throw new NotFoundException('Aucun personnage trouvé');
|
||||
|
||||
const totalToDistribute =
|
||||
(dto.force ?? 0) + (dto.agilite ?? 0) + (dto.intelligence ?? 0) +
|
||||
(dto.chance ?? 0) + (dto.vitalite ?? 0);
|
||||
|
||||
if (totalToDistribute <= 0) {
|
||||
throw new BadRequestException('Aucun point à distribuer');
|
||||
}
|
||||
|
||||
if (totalToDistribute > (character.statPoints ?? 0)) {
|
||||
throw new BadRequestException(
|
||||
`Points insuffisants (${character.statPoints ?? 0} disponibles, ${totalToDistribute} demandés)`,
|
||||
);
|
||||
}
|
||||
|
||||
character.force += dto.force ?? 0;
|
||||
character.agilite += dto.agilite ?? 0;
|
||||
character.intelligence += dto.intelligence ?? 0;
|
||||
character.chance += dto.chance ?? 0;
|
||||
character.vitalite += dto.vitalite ?? 0;
|
||||
character.statPoints = (character.statPoints ?? 0) - totalToDistribute;
|
||||
|
||||
// Vitalité augmente HP max (+10 par point)
|
||||
const vitaliteAdded = dto.vitalite ?? 0;
|
||||
if (vitaliteAdded > 0) {
|
||||
character.hpMax += vitaliteAdded * 10;
|
||||
character.hpCurrent += vitaliteAdded * 10; // bonus immédiat
|
||||
}
|
||||
|
||||
await manager.save(character);
|
||||
|
||||
return {
|
||||
statPoints: character.statPoints,
|
||||
stats: {
|
||||
force: character.force,
|
||||
agilite: character.agilite,
|
||||
intelligence: character.intelligence,
|
||||
chance: character.chance,
|
||||
vitalite: character.vitalite,
|
||||
},
|
||||
hpMax: character.hpMax,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async rest(user: User) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const character = await manager
|
||||
.getRepository(Character)
|
||||
.createQueryBuilder('c')
|
||||
.setLock('pessimistic_write')
|
||||
.where('c.user_id = :userId', { userId: user.id })
|
||||
.getOne();
|
||||
|
||||
if (!character) throw new NotFoundException('Aucun personnage trouvé');
|
||||
|
||||
if (character.hpCurrent >= character.hpMax) {
|
||||
throw new BadRequestException('PV déjà au maximum');
|
||||
}
|
||||
|
||||
// Calculer endurance
|
||||
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES);
|
||||
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||
|
||||
if (enduranceCurrent < REST_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${REST_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
|
||||
const hpBefore = character.hpCurrent;
|
||||
character.hpCurrent = Math.min(
|
||||
character.hpMax,
|
||||
character.hpCurrent + Math.floor(character.hpMax * REST_HP_REGEN_RATIO),
|
||||
);
|
||||
character.enduranceSaved = enduranceCurrent - REST_ENDURANCE_COST;
|
||||
character.lastEnduranceTs = new Date();
|
||||
|
||||
await manager.save(character);
|
||||
|
||||
return {
|
||||
hpBefore,
|
||||
hpAfter: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
healed: character.hpCurrent - hpBefore,
|
||||
enduranceCurrent: character.enduranceSaved,
|
||||
enduranceMax: character.enduranceMax,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
18
src/character/dto/distribute-stats.dto.ts
Normal file
18
src/character/dto/distribute-stats.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsInt, Min, IsOptional } from 'class-validator';
|
||||
|
||||
export class DistributeStatsDto {
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
force?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
agilite?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
intelligence?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
chance?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
vitalite?: number = 0;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Monster } from '../monster/monster.entity';
|
||||
@@ -15,6 +16,7 @@ export class CombatLog {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Monster } from '../monster/monster.entity';
|
||||
@@ -35,219 +35,217 @@ export class CombatService {
|
||||
private readonly materialService: MaterialService,
|
||||
private readonly communityService: CommunityService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
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
|
||||
// Charger le monstre (hors transaction — lecture seule)
|
||||
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);
|
||||
// Transaction isolée — empêche les combats simultanés sur le même perso
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// SELECT ... FOR UPDATE — verrouille le personnage
|
||||
const character = await manager
|
||||
.getRepository(Character)
|
||||
.createQueryBuilder('c')
|
||||
.setLock('pessimistic_write')
|
||||
.where('c.user_id = :userId', { userId: user.id })
|
||||
.getOne();
|
||||
|
||||
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
if (!character) throw new BadRequestException('Aucun personnage trouvé');
|
||||
|
||||
if (character.hpCurrent <= 0) {
|
||||
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Charger l'équipement actif du personnage
|
||||
const equipped = await this.itemService.getEquippedItems(character.id);
|
||||
const FORGE_BONUS_PER_LEVEL = 2;
|
||||
const weaponAttack = equipped.weapon
|
||||
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||
: 0;
|
||||
const armorDefense = equipped.armor
|
||||
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||
: 0;
|
||||
|
||||
// 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: weaponAttack,
|
||||
defense: armorDefense,
|
||||
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);
|
||||
}
|
||||
|
||||
// Track total gold earned (for achievements)
|
||||
if (result.winner === 'player') {
|
||||
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned;
|
||||
}
|
||||
|
||||
// Sauvegarder l'endurance (lazy reset)
|
||||
character.hpCurrent = newHp;
|
||||
character.enduranceSaved = newEnduranceSaved;
|
||||
character.lastEnduranceTs = new Date();
|
||||
await this.characterRepository.save(character);
|
||||
|
||||
// Emit achievement & community events
|
||||
if (result.winner === 'player') {
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id,
|
||||
type: 'combat_wins',
|
||||
increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id,
|
||||
type: 'level_reached',
|
||||
increment: 0,
|
||||
absolute: character.level,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id,
|
||||
type: 'gold_accumulated',
|
||||
increment: 0,
|
||||
absolute: Number(character.totalGoldEarned),
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id,
|
||||
type: 'total_monsters_killed',
|
||||
increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id,
|
||||
type: 'total_gold_earned',
|
||||
increment: result.goldEarned,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply XP boost from community
|
||||
if (result.winner === 'player') {
|
||||
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
|
||||
if (xpBoost > 1.0) {
|
||||
const bonusXp = Math.floor(result.xpEarned * (xpBoost - 1));
|
||||
if (bonusXp > 0) {
|
||||
const boosted = applyXpGain(character.level, character.xp, bonusXp);
|
||||
character.xp = boosted.newXp;
|
||||
character.level = boosted.newLevel;
|
||||
character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained;
|
||||
await this.characterRepository.save(character);
|
||||
}
|
||||
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id
|
||||
let lootMaterial: { name: string; quantity: number } | null = null;
|
||||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||||
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||||
lootMaterial = { name: 'matériau', quantity: 1 };
|
||||
}
|
||||
|
||||
// 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.`);
|
||||
if (character.hpCurrent <= 0) {
|
||||
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
||||
}
|
||||
} else {
|
||||
summaryParts.push(`Défaite au tour ${result.totalRounds}. Retour à l'auberge.`);
|
||||
if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`);
|
||||
}
|
||||
|
||||
if (lootMaterial) {
|
||||
summaryParts.push(`Loot : 1 matériau obtenu !`);
|
||||
}
|
||||
// Charger l'équipement actif du personnage
|
||||
const equipped = await this.itemService.getEquippedItems(character.id);
|
||||
const FORGE_BONUS_PER_LEVEL = 2;
|
||||
const weaponAttack = equipped.weapon
|
||||
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||
: 0;
|
||||
const armorDefense = equipped.armor
|
||||
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||
: 0;
|
||||
|
||||
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,
|
||||
loot: lootMaterial,
|
||||
},
|
||||
character: {
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
gold: character.gold,
|
||||
// Item stat bonuses
|
||||
const itemForceBonus = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0);
|
||||
const itemAgiliteBonus = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0);
|
||||
const itemIntelligenceBonus = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0);
|
||||
const itemChanceBonus = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0);
|
||||
|
||||
// Construire les stats des combattants
|
||||
const playerStats: CombatantStats = {
|
||||
name: character.name,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat
|
||||
enduranceMax: character.enduranceMax,
|
||||
statPoints: character.statPoints ?? 0,
|
||||
},
|
||||
};
|
||||
force: character.force + itemForceBonus,
|
||||
agilite: character.agilite + itemAgiliteBonus,
|
||||
intelligence: character.intelligence + itemIntelligenceBonus,
|
||||
chance: character.chance + itemChanceBonus,
|
||||
attack: weaponAttack,
|
||||
defense: armorDefense,
|
||||
attackType: dto.attackType,
|
||||
};
|
||||
|
||||
const monsterStats: CombatantStats = {
|
||||
name: monster.name,
|
||||
hpCurrent: monster.hp,
|
||||
hpMax: monster.hp,
|
||||
force: 0,
|
||||
agilite: 0,
|
||||
intelligence: 0,
|
||||
chance: 0,
|
||||
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;
|
||||
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + 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 le personnage (dans la transaction)
|
||||
character.hpCurrent = newHp;
|
||||
character.enduranceSaved = newEnduranceSaved;
|
||||
character.lastEnduranceTs = new Date();
|
||||
await manager.save(character);
|
||||
|
||||
// Apply XP boost from community (dans la transaction)
|
||||
if (result.winner === 'player') {
|
||||
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
|
||||
if (xpBoost > 1.0) {
|
||||
const bonusXp = Math.floor(result.xpEarned * (xpBoost - 1));
|
||||
if (bonusXp > 0) {
|
||||
const boosted = applyXpGain(character.level, character.xp, bonusXp);
|
||||
character.xp = boosted.newXp;
|
||||
character.level = boosted.newLevel;
|
||||
character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained;
|
||||
await manager.save(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loot matériaux — 40% de chance après victoire
|
||||
let lootMaterial: { name: string; quantity: number } | null = null;
|
||||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||||
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||||
lootMaterial = { name: 'matériau', quantity: 1 };
|
||||
}
|
||||
|
||||
// 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 manager.save(combatLog);
|
||||
|
||||
// Events émis après la transaction (fire-and-forget)
|
||||
if (result.winner === 'player') {
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id, type: 'combat_wins', increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id, type: 'level_reached', increment: 0, absolute: character.level,
|
||||
});
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: character.id, type: 'gold_accumulated', increment: 0, absolute: Number(character.totalGoldEarned),
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id, type: 'total_monsters_killed', increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: character.id, type: 'total_gold_earned', increment: result.goldEarned,
|
||||
});
|
||||
}
|
||||
|
||||
// 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.`);
|
||||
}
|
||||
|
||||
if (lootMaterial) {
|
||||
summaryParts.push(`Loot : 1 matériau obtenu !`);
|
||||
}
|
||||
|
||||
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,
|
||||
loot: lootMaterial,
|
||||
},
|
||||
character: {
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
gold: character.gold,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
enduranceCurrent: character.enduranceSaved,
|
||||
enduranceMax: character.enduranceMax,
|
||||
statPoints: character.statPoints ?? 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(user: User) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CommunityGoal } from './community-goal.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
@@ -23,6 +24,7 @@ export class CommunityContribution {
|
||||
communityGoal: CommunityGoal;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Recipe } from './recipe.entity';
|
||||
@@ -15,6 +16,7 @@ export class CraftJob {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -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}].`,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
|
||||
@@ -20,6 +21,7 @@ export class HallOfFame {
|
||||
character: Character;
|
||||
|
||||
@Column({ length: 7 })
|
||||
@Index()
|
||||
period: string; // 'YYYY-MM'
|
||||
|
||||
@Column()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Item } from './item.entity';
|
||||
@@ -15,6 +16,7 @@ export class CharacterItem {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Item } from './item.entity';
|
||||
import { CharacterItem } from './character-item.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
@@ -15,6 +15,7 @@ export class ItemService {
|
||||
private readonly charItemRepository: Repository<CharacterItem>,
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
@@ -30,29 +31,39 @@ export class ItemService {
|
||||
}
|
||||
|
||||
async equip(charItemId: string, user: User) {
|
||||
const char = await this.getCharacter(user);
|
||||
const charItem = await this.charItemRepository.findOne({
|
||||
where: { id: charItemId, characterId: char.id },
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const char = await this.getCharacter(user);
|
||||
const charItemRepo = manager.getRepository(CharacterItem);
|
||||
|
||||
// Lock l'item cible
|
||||
const charItem = await charItemRepo
|
||||
.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');
|
||||
|
||||
// Déséquiper l'item du même slot
|
||||
const currentEquipped = await charItemRepo
|
||||
.createQueryBuilder('ci')
|
||||
.setLock('pessimistic_write')
|
||||
.leftJoinAndSelect('ci.item', 'item')
|
||||
.where('ci.character_id = :cid', { cid: char.id })
|
||||
.andWhere('ci.equipped = true')
|
||||
.andWhere('item.type = :type', { type: charItem.item.type })
|
||||
.getOne();
|
||||
|
||||
if (currentEquipped) {
|
||||
currentEquipped.equipped = false;
|
||||
await charItemRepo.save(currentEquipped);
|
||||
}
|
||||
|
||||
charItem.equipped = true;
|
||||
await charItemRepo.save(charItem);
|
||||
return { equipped: true, item: charItem };
|
||||
});
|
||||
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
||||
|
||||
// Déséquiper l'item du même slot (type) si existe
|
||||
const currentEquipped = await this.charItemRepository
|
||||
.createQueryBuilder('ci')
|
||||
.leftJoinAndSelect('ci.item', 'item')
|
||||
.where('ci.characterId = :cid', { cid: char.id })
|
||||
.andWhere('ci.equipped = true')
|
||||
.andWhere('item.type = :type', { type: charItem.item.type })
|
||||
.getOne();
|
||||
|
||||
if (currentEquipped) {
|
||||
currentEquipped.equipped = false;
|
||||
await this.charItemRepository.save(currentEquipped);
|
||||
}
|
||||
|
||||
charItem.equipped = true;
|
||||
await this.charItemRepository.save(charItem);
|
||||
return { equipped: true, item: charItem };
|
||||
}
|
||||
|
||||
async unequip(slot: 'weapon' | 'armor', user: User) {
|
||||
|
||||
@@ -4,16 +4,19 @@ import {
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Material } from './material.entity';
|
||||
|
||||
@Entity('character_materials')
|
||||
@Index(['characterId', 'materialId'])
|
||||
export class CharacterMaterial {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThan, Repository } from 'typeorm';
|
||||
import { DataSource, MoreThan, Repository } from 'typeorm';
|
||||
import { Material } from './material.entity';
|
||||
import { CharacterMaterial } from './character-material.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
@@ -15,6 +15,7 @@ export class MaterialService {
|
||||
private readonly charMatRepository: Repository<CharacterMaterial>,
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
@@ -39,18 +40,27 @@ export class MaterialService {
|
||||
return this.charMatRepository.save(entry);
|
||||
}
|
||||
|
||||
// Appelé par CraftService pour consommer les ingrédients
|
||||
// Appelé par CraftService — consommation atomique en transaction
|
||||
async consumeMaterials(characterId: string, ingredients: { materialId: string; quantity: number }[]): Promise<void> {
|
||||
for (const ing of ingredients) {
|
||||
const entry = await this.charMatRepository.findOne({
|
||||
where: { characterId, materialId: ing.materialId },
|
||||
});
|
||||
if (!entry || entry.quantity < ing.quantity) {
|
||||
throw new BadRequestException('Matériaux insuffisants pour ce craft');
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const charMatRepo = manager.getRepository(CharacterMaterial);
|
||||
|
||||
for (const ing of ingredients) {
|
||||
// SELECT ... FOR UPDATE — lock chaque entrée matériau
|
||||
const entry = await charMatRepo
|
||||
.createQueryBuilder('cm')
|
||||
.setLock('pessimistic_write')
|
||||
.where('cm.character_id = :characterId', { characterId })
|
||||
.andWhere('cm.material_id = :materialId', { materialId: ing.materialId })
|
||||
.getOne();
|
||||
|
||||
if (!entry || entry.quantity < ing.quantity) {
|
||||
throw new BadRequestException('Matériaux insuffisants pour ce craft');
|
||||
}
|
||||
entry.quantity -= ing.quantity;
|
||||
await charMatRepo.save(entry);
|
||||
}
|
||||
entry.quantity -= ing.quantity;
|
||||
await this.charMatRepository.save(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getCharacter(user: User): Promise<Character> {
|
||||
|
||||
Reference in New Issue
Block a user