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

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:
2026-03-24 15:55:50 +01:00
parent 708352be65
commit 6df11f2860
17 changed files with 580 additions and 408 deletions

View File

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

View File

@@ -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,
};
});
}
}

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