diff --git a/SPRINT5.md b/SPRINT5.md new file mode 100644 index 0000000..7677e7a --- /dev/null +++ b/SPRINT5.md @@ -0,0 +1,51 @@ +# TetaRdPG — Brief Sprint 5 + +> Statut : en cours +> Objectif : Audit API, corrections intégrité, features manquantes, équilibrage +> Stack : NestJS · MySQL · TypeORM +> Prérequis : Sprint 4 livré ✅ + +--- + +## Plan Sprint 5 + +### P0 — Exploits / Intégrité données + +- [ ] Race condition combat — transaction isolée pour endurance + character save +- [ ] Race condition forge — transaction lock sur forgeLevel +- [ ] Race condition craft — consumeMaterials atomique (transaction) +- [ ] Race condition equip — transaction sur item equip/unequip +- [ ] Forge gratuite — ajouter coût (or + endurance) + +### P1 — Features manquantes (gameplay) + +- [ ] Endpoint distribution stat points (POST /api/characters/stats) +- [ ] Endpoint recovery HP — auberge/repos (POST /api/characters/rest) +- [ ] Item stat bonuses appliqués au combat (force_bonus, agilite_bonus, etc.) + +### P2 — Indexes DB + +- [ ] character_id sur : character_items, character_materials, combat_logs, craft_jobs, player_achievements, community_contributions +- [ ] equipped sur character_items +- [ ] period sur hall_of_fame + +### P3 — Cleanup + +- [ ] Supprimer @nestjs/jwt de package.json +- [ ] Supprimer pg de package.json +- [ ] Migrer seed.ts vers MySQL (AppDataSource) +- [ ] ProcessedEvent TTL (cleanup > 90 jours) + +--- + +## Critères de validation + +- [ ] 2 combats simultanés sur même perso → 1 seul passe, l'autre 409 +- [ ] Forge déduit or + endurance +- [ ] Forge sans or/endurance → 400 +- [ ] Craft démarre en transaction atomique (matériaux non exploitables) +- [ ] POST /api/characters/stats → distribue les points, valide total +- [ ] POST /api/characters/rest → regen HP (coût endurance) +- [ ] Combat applique item stat bonuses dans les calculs +- [ ] Indexes créés (SHOW INDEX FROM) +- [ ] @nestjs/jwt et pg absents de package.json diff --git a/package-lock.json b/package-lock.json index bd22819..7fbb7b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^3.0.1", - "@nestjs/jwt": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/throttler": "^5.0.0", "@nestjs/typeorm": "^10.0.0", @@ -21,7 +20,6 @@ "cookie-parser": "^1.4.6", "helmet": "^7.0.0", "mysql2": "^3.20.0", - "pg": "^8.11.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "typeorm": "^0.3.20" @@ -1630,19 +1628,6 @@ "@nestjs/core": "^10.0.0 || ^11.0.0" } }, - "node_modules/@nestjs/jwt": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", - "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", - "license": "MIT", - "dependencies": { - "@types/jsonwebtoken": "9.0.5", - "jsonwebtoken": "9.0.2" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" - } - }, "node_modules/@nestjs/platform-express": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", @@ -2072,15 +2057,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2908,12 +2884,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3634,15 +3604,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5795,55 +5756,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.2", - "safe-buffer": "^5.0.1" - } - }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5910,42 +5822,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5953,12 +5829,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6664,6 +6534,8 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -6691,19 +6563,24 @@ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/pg-connection-string": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -6713,6 +6590,8 @@ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "pg": ">=8.0" } @@ -6721,13 +6600,17 @@ "version": "1.13.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -6744,6 +6627,8 @@ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "split2": "^4.1.0" } @@ -6815,6 +6700,8 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -6824,6 +6711,8 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6833,6 +6722,8 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6842,6 +6733,8 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xtend": "^4.0.0" }, @@ -7239,6 +7132,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7493,6 +7387,8 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">= 10.x" } diff --git a/package.json b/package.json index c5b9137..d7d223d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^3.0.1", - "@nestjs/jwt": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/throttler": "^5.0.0", "@nestjs/typeorm": "^10.0.0", @@ -29,7 +28,6 @@ "cookie-parser": "^1.4.6", "helmet": "^7.0.0", "mysql2": "^3.20.0", - "pg": "^8.11.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "typeorm": "^0.3.20" diff --git a/src/achievement/player-achievement.entity.ts b/src/achievement/player-achievement.entity.ts index 863859c..58c0292 100644 --- a/src/achievement/player-achievement.entity.ts +++ b/src/achievement/player-achievement.entity.ts @@ -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) diff --git a/src/character/character.controller.ts b/src/character/character.controller.ts index 2dc6d9e..dc9c554 100644 --- a/src/character/character.controller.ts +++ b/src/character/character.controller.ts @@ -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); + } } diff --git a/src/character/character.service.ts b/src/character/character.service.ts index e4bb083..3925f58 100644 --- a/src/character/character.service.ts +++ b/src/character/character.service.ts @@ -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, @InjectRepository(LevelThreshold) private readonly levelThresholdRepository: Repository, + 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, + }; + }); + } } diff --git a/src/character/dto/distribute-stats.dto.ts b/src/character/dto/distribute-stats.dto.ts new file mode 100644 index 0000000..2a07085 --- /dev/null +++ b/src/character/dto/distribute-stats.dto.ts @@ -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; +} diff --git a/src/combat/combat-log.entity.ts b/src/combat/combat-log.entity.ts index 1e83be9..921666d 100644 --- a/src/combat/combat-log.entity.ts +++ b/src/combat/combat-log.entity.ts @@ -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) diff --git a/src/combat/combat.service.ts b/src/combat/combat.service.ts index 76fb46d..550b806 100644 --- a/src/combat/combat.service.ts +++ b/src/combat/combat.service.ts @@ -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) { diff --git a/src/community/community-contribution.entity.ts b/src/community/community-contribution.entity.ts index 537944c..39afd38 100644 --- a/src/community/community-contribution.entity.ts +++ b/src/community/community-contribution.entity.ts @@ -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) diff --git a/src/craft/craft-job.entity.ts b/src/craft/craft-job.entity.ts index 61f62fe..7482709 100644 --- a/src/craft/craft-job.entity.ts +++ b/src/craft/craft-job.entity.ts @@ -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) diff --git a/src/forge/forge.service.ts b/src/forge/forge.service.ts index 65a4e99..f3aaa62 100644 --- a/src/forge/forge.service.ts +++ b/src/forge/forge.service.ts @@ -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 = { + 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 = { @@ -26,57 +37,98 @@ export class ForgeService { @InjectRepository(Character) private readonly characterRepository: Repository, 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}].`, - }; + }); } } diff --git a/src/halloffame/hall-of-fame.entity.ts b/src/halloffame/hall-of-fame.entity.ts index c02374a..c059fbb 100644 --- a/src/halloffame/hall-of-fame.entity.ts +++ b/src/halloffame/hall-of-fame.entity.ts @@ -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() diff --git a/src/item/character-item.entity.ts b/src/item/character-item.entity.ts index 54a4d8c..49f9bc4 100644 --- a/src/item/character-item.entity.ts +++ b/src/item/character-item.entity.ts @@ -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) diff --git a/src/item/item.service.ts b/src/item/item.service.ts index 218d385..ec0f996 100644 --- a/src/item/item.service.ts +++ b/src/item/item.service.ts @@ -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, @InjectRepository(Character) private readonly characterRepository: Repository, + 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) { diff --git a/src/material/character-material.entity.ts b/src/material/character-material.entity.ts index 39c9315..358826d 100644 --- a/src/material/character-material.entity.ts +++ b/src/material/character-material.entity.ts @@ -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) diff --git a/src/material/material.service.ts b/src/material/material.service.ts index 725f947..1f18651 100644 --- a/src/material/material.service.ts +++ b/src/material/material.service.ts @@ -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, @InjectRepository(Character) private readonly characterRepository: Repository, + 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 { - 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 {