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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user