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

51
SPRINT5.md Normal file
View File

@@ -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

162
package-lock.json generated
View File

@@ -12,7 +12,6 @@
"@nestjs/config": "^3.0.0", "@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^10.0.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^5.0.0", "@nestjs/throttler": "^5.0.0",
"@nestjs/typeorm": "^10.0.0", "@nestjs/typeorm": "^10.0.0",
@@ -21,7 +20,6 @@
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"mysql2": "^3.20.0", "mysql2": "^3.20.0",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"typeorm": "^0.3.20" "typeorm": "^0.3.20"
@@ -1630,19 +1628,6 @@
"@nestjs/core": "^10.0.0 || ^11.0.0" "@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": { "node_modules/@nestjs/platform-express": {
"version": "10.4.22", "version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz",
@@ -2072,15 +2057,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -2908,12 +2884,6 @@
"ieee754": "^1.1.13" "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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -3634,15 +3604,6 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5795,55 +5756,6 @@
"graceful-fs": "^4.1.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": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -5910,42 +5822,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "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": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -5953,12 +5829,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "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", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -6691,19 +6563,24 @@
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/pg-connection-string": { "node_modules/pg-connection-string": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/pg-int8": { "node_modules/pg-int8": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4.0.0" "node": ">=4.0.0"
} }
@@ -6713,6 +6590,8 @@
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"peerDependencies": { "peerDependencies": {
"pg": ">=8.0" "pg": ">=8.0"
} }
@@ -6721,13 +6600,17 @@
"version": "1.13.0", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/pg-types": { "node_modules/pg-types": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"pg-int8": "1.0.1", "pg-int8": "1.0.1",
"postgres-array": "~2.0.0", "postgres-array": "~2.0.0",
@@ -6744,6 +6627,8 @@
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"split2": "^4.1.0" "split2": "^4.1.0"
} }
@@ -6815,6 +6700,8 @@
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@@ -6824,6 +6711,8 @@
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6833,6 +6722,8 @@
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6842,6 +6733,8 @@
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"xtend": "^4.0.0" "xtend": "^4.0.0"
}, },
@@ -7239,6 +7132,7 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -7493,6 +7387,8 @@
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.x" "node": ">= 10.x"
} }

View File

@@ -20,7 +20,6 @@
"@nestjs/config": "^3.0.0", "@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^10.0.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^5.0.0", "@nestjs/throttler": "^5.0.0",
"@nestjs/typeorm": "^10.0.0", "@nestjs/typeorm": "^10.0.0",
@@ -29,7 +28,6 @@
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"mysql2": "^3.20.0", "mysql2": "^3.20.0",
"pg": "^8.11.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"typeorm": "^0.3.20" "typeorm": "^0.3.20"

View File

@@ -5,6 +5,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Unique, Unique,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Achievement } from './achievement.entity'; import { Achievement } from './achievement.entity';
@@ -16,6 +17,7 @@ export class PlayerAchievement {
id: string; id: string;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)
@@ -23,6 +25,7 @@ export class PlayerAchievement {
character: Character; character: Character;
@Column({ name: 'achievement_id' }) @Column({ name: 'achievement_id' })
@Index()
achievementId: string; achievementId: string;
@ManyToOne(() => Achievement) @ManyToOne(() => Achievement)

View File

@@ -11,6 +11,7 @@ import {
import { Request } from 'express'; import { Request } from 'express';
import { CharacterService } from './character.service'; import { CharacterService } from './character.service';
import { CreateCharacterDto } from './dto/create-character.dto'; import { CreateCharacterDto } from './dto/create-character.dto';
import { DistributeStatsDto } from './dto/distribute-stats.dto';
import { AuthGuard } from '../auth/guards/auth.guard'; import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
@@ -37,4 +38,19 @@ export class CharacterController {
getEndurance(@Req() req: Request & { user: User }) { getEndurance(@Req() req: Request & { user: User }) {
return this.characterService.getEndurance(req.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, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { Character } from './entities/character.entity'; import { Character } from './entities/character.entity';
import { LevelThreshold } from './entities/level-threshold.entity'; import { LevelThreshold } from './entities/level-threshold.entity';
import { CreateCharacterDto } from './dto/create-character.dto'; import { CreateCharacterDto } from './dto/create-character.dto';
import { DistributeStatsDto } from './dto/distribute-stats.dto';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer 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 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() @Injectable()
export class CharacterService { export class CharacterService {
@@ -21,6 +24,7 @@ export class CharacterService {
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
@InjectRepository(LevelThreshold) @InjectRepository(LevelThreshold)
private readonly levelThresholdRepository: Repository<LevelThreshold>, private readonly levelThresholdRepository: Repository<LevelThreshold>,
private readonly dataSource: DataSource,
) {} ) {}
// Pattern lazy calculation — pas de timer actif // Pattern lazy calculation — pas de timer actif
@@ -94,4 +98,106 @@ export class CharacterService {
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES, 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;
}

View File

@@ -5,6 +5,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
CreateDateColumn, CreateDateColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Monster } from '../monster/monster.entity'; import { Monster } from '../monster/monster.entity';
@@ -15,6 +16,7 @@ export class CombatLog {
id: string; id: string;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)

View File

@@ -1,6 +1,6 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Monster } from '../monster/monster.entity'; import { Monster } from '../monster/monster.entity';
@@ -35,18 +35,25 @@ export class CombatService {
private readonly materialService: MaterialService, private readonly materialService: MaterialService,
private readonly communityService: CommunityService, private readonly communityService: CommunityService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly dataSource: DataSource,
) {} ) {}
async startCombat(dto: StartCombatDto, user: User) { async startCombat(dto: StartCombatDto, user: User) {
// Charger le personnage // Charger le monstre (hors transaction — lecture seule)
const character = await this.characterRepository.findOne({
where: { userId: user.id },
});
if (!character) throw new BadRequestException('Aucun personnage trouvé');
// Charger le monstre
const monster = await this.monsterService.findOne(dto.monsterId); const monster = await this.monsterService.findOne(dto.monsterId);
// 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 (!character) throw new BadRequestException('Aucun personnage trouvé');
// Calculer l'endurance actuelle (lazy pattern) // Calculer l'endurance actuelle (lazy pattern)
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000; const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsedMinutes / 6); const recharge = Math.floor(elapsedMinutes / 6);
@@ -72,15 +79,21 @@ export class CombatService {
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL ? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0; : 0;
// 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 // Construire les stats des combattants
const playerStats: CombatantStats = { const playerStats: CombatantStats = {
name: character.name, name: character.name,
hpCurrent: character.hpCurrent, hpCurrent: character.hpCurrent,
hpMax: character.hpMax, hpMax: character.hpMax,
force: character.force, force: character.force + itemForceBonus,
agilite: character.agilite, agilite: character.agilite + itemAgiliteBonus,
intelligence: character.intelligence, intelligence: character.intelligence + itemIntelligenceBonus,
chance: character.chance, chance: character.chance + itemChanceBonus,
attack: weaponAttack, attack: weaponAttack,
defense: armorDefense, defense: armorDefense,
attackType: dto.attackType, attackType: dto.attackType,
@@ -93,7 +106,7 @@ export class CombatService {
force: 0, force: 0,
agilite: 0, agilite: 0,
intelligence: 0, intelligence: 0,
chance: 0, // pas de crit/esquive pour les monstres Sprint 2 chance: 0,
attack: monster.attack, attack: monster.attack,
defense: monster.defense, defense: monster.defense,
attackType: monster.attackType, attackType: monster.attackType,
@@ -121,6 +134,7 @@ export class CombatService {
character.level = levelUpData.newLevel; character.level = levelUpData.newLevel;
character.statPoints = (character.statPoints ?? 0) + levelUpData.statPointsGained; character.statPoints = (character.statPoints ?? 0) + levelUpData.statPointsGained;
character.gold += result.goldEarned; 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)); newHp = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO));
} else { } else {
// Défaite : retour auberge + pénalités // Défaite : retour auberge + pénalités
@@ -130,49 +144,13 @@ export class CombatService {
character.gold = Math.max(0, character.gold - goldLost); character.gold = Math.max(0, character.gold - goldLost);
} }
// Track total gold earned (for achievements) // Sauvegarder le personnage (dans la transaction)
if (result.winner === 'player') {
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned;
}
// Sauvegarder l'endurance (lazy reset)
character.hpCurrent = newHp; character.hpCurrent = newHp;
character.enduranceSaved = newEnduranceSaved; character.enduranceSaved = newEnduranceSaved;
character.lastEnduranceTs = new Date(); character.lastEnduranceTs = new Date();
await this.characterRepository.save(character); await manager.save(character);
// Emit achievement & community events // Apply XP boost from community (dans la transaction)
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') { if (result.winner === 'player') {
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost'); const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
if (xpBoost > 1.0) { if (xpBoost > 1.0) {
@@ -182,12 +160,12 @@ export class CombatService {
character.xp = boosted.newXp; character.xp = boosted.newXp;
character.level = boosted.newLevel; character.level = boosted.newLevel;
character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained; character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained;
await this.characterRepository.save(character); await manager.save(character);
} }
} }
} }
// Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id // Loot matériaux — 40% de chance après victoire
let lootMaterial: { name: string; quantity: number } | null = null; let lootMaterial: { name: string; quantity: number } | null = null;
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) { if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1); await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
@@ -205,7 +183,26 @@ export class CombatService {
goldEarned: result.goldEarned, goldEarned: result.goldEarned,
levelUp: levelUpData.levelsGained > 0, levelUp: levelUpData.levelsGained > 0,
}); });
await this.combatLogRepository.save(combatLog); 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 // Construire la réponse
const summaryParts: string[] = []; const summaryParts: string[] = [];
@@ -243,11 +240,12 @@ export class CombatService {
gold: character.gold, gold: character.gold,
hpCurrent: character.hpCurrent, hpCurrent: character.hpCurrent,
hpMax: character.hpMax, hpMax: character.hpMax,
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax, enduranceMax: character.enduranceMax,
statPoints: character.statPoints ?? 0, statPoints: character.statPoints ?? 0,
}, },
}; };
});
} }
async getHistory(user: User) { async getHistory(user: User) {

View File

@@ -5,6 +5,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Unique, Unique,
Index,
} from 'typeorm'; } from 'typeorm';
import { CommunityGoal } from './community-goal.entity'; import { CommunityGoal } from './community-goal.entity';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
@@ -23,6 +24,7 @@ export class CommunityContribution {
communityGoal: CommunityGoal; communityGoal: CommunityGoal;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)

View File

@@ -5,6 +5,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
CreateDateColumn, CreateDateColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Recipe } from './recipe.entity'; import { Recipe } from './recipe.entity';
@@ -15,6 +16,7 @@ export class CraftJob {
id: string; id: string;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)

View File

@@ -1,13 +1,24 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CharacterItem } from '../item/character-item.entity'; import { CharacterItem } from '../item/character-item.entity';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
const MAX_FORGE_LEVEL = 5; 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) // Risque d'échec par niveau cible (GDD exact)
const FORGE_FAIL_CHANCE: Record<number, number> = { const FORGE_FAIL_CHANCE: Record<number, number> = {
@@ -26,49 +37,88 @@ export class ForgeService {
@InjectRepository(Character) @InjectRepository(Character)
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly dataSource: DataSource,
) {} ) {}
async upgradeItem(charItemId: string, user: User) { async upgradeItem(charItemId: string, user: User) {
const char = await this.characterRepository.findOne({ where: { userId: user.id } }); 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é'); if (!char) throw new BadRequestException('Aucun personnage trouvé');
const charItem = await this.charItemRepository.findOne({ // Lock l'item
where: { id: charItemId, characterId: char.id }, 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) throw new NotFoundException('Item non trouvé dans l\'inventaire');
if (charItem.forgeLevel >= MAX_FORGE_LEVEL) { if (charItem.forgeLevel >= MAX_FORGE_LEVEL) {
throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`); throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`);
} }
const targetLevel = charItem.forgeLevel + 1; const targetLevel = charItem.forgeLevel + 1;
const goldCost = FORGE_GOLD_COST[targetLevel] ?? 0;
// 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);
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 failChance = FORGE_FAIL_CHANCE[targetLevel] ?? 0;
const success = Math.random() >= failChance; const success = Math.random() >= failChance;
if (success) { if (success) {
charItem.forgeLevel = targetLevel; charItem.forgeLevel = targetLevel;
await this.charItemRepository.save(charItem); await manager.save(charItem);
// Emit achievement & community events
this.eventEmitter.emit('achievement.check', { this.eventEmitter.emit('achievement.check', {
characterId: char.id, characterId: char.id, type: 'forge_upgrades', increment: 1,
type: 'forge_upgrades',
increment: 1,
}); });
this.eventEmitter.emit('community.contribute', { this.eventEmitter.emit('community.contribute', {
characterId: char.id, characterId: char.id, type: 'total_forge_upgrades', increment: 1,
type: 'total_forge_upgrades',
increment: 1,
}); });
}
await manager.save(char);
const statLabel = charItem.item.type === 'weapon' const statLabel = charItem.item.type === 'weapon'
? `+${FORGE_BONUS_PER_LEVEL} ATK` ? `+${FORGE_BONUS_PER_LEVEL} ATK`
: `+${FORGE_BONUS_PER_LEVEL} DEF`; : `+${FORGE_BONUS_PER_LEVEL} DEF`;
if (success) {
return { return {
success: true, success: true,
forgeLevel: charItem.forgeLevel, forgeLevel: charItem.forgeLevel,
item: charItem.item.name, item: charItem.item.name,
message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}).`, goldSpent: goldCost,
message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}). -${goldCost} Or.`,
}; };
} }
@@ -76,7 +126,9 @@ export class ForgeService {
success: false, success: false,
forgeLevel: charItem.forgeLevel, forgeLevel: charItem.forgeLevel,
item: charItem.item.name, item: charItem.item.name,
message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}].`, goldSpent: goldCost,
message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}]. -${goldCost} Or perdus.`,
}; };
});
} }
} }

View File

@@ -4,6 +4,7 @@ import {
Column, Column,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
@@ -20,6 +21,7 @@ export class HallOfFame {
character: Character; character: Character;
@Column({ length: 7 }) @Column({ length: 7 })
@Index()
period: string; // 'YYYY-MM' period: string; // 'YYYY-MM'
@Column() @Column()

View File

@@ -5,6 +5,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
CreateDateColumn, CreateDateColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Item } from './item.entity'; import { Item } from './item.entity';
@@ -15,6 +16,7 @@ export class CharacterItem {
id: string; id: string;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)

View File

@@ -1,6 +1,6 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { Item } from './item.entity'; import { Item } from './item.entity';
import { CharacterItem } from './character-item.entity'; import { CharacterItem } from './character-item.entity';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
@@ -15,6 +15,7 @@ export class ItemService {
private readonly charItemRepository: Repository<CharacterItem>, private readonly charItemRepository: Repository<CharacterItem>,
@InjectRepository(Character) @InjectRepository(Character)
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
private readonly dataSource: DataSource,
) {} ) {}
findAll() { findAll() {
@@ -30,29 +31,39 @@ export class ItemService {
} }
async equip(charItemId: string, user: User) { async equip(charItemId: string, user: User) {
return this.dataSource.transaction(async (manager) => {
const char = await this.getCharacter(user); const char = await this.getCharacter(user);
const charItem = await this.charItemRepository.findOne({ const charItemRepo = manager.getRepository(CharacterItem);
where: { id: charItemId, characterId: char.id },
}); // 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'); if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
// Déséquiper l'item du même slot (type) si existe // Déséquiper l'item du même slot
const currentEquipped = await this.charItemRepository const currentEquipped = await charItemRepo
.createQueryBuilder('ci') .createQueryBuilder('ci')
.setLock('pessimistic_write')
.leftJoinAndSelect('ci.item', 'item') .leftJoinAndSelect('ci.item', 'item')
.where('ci.characterId = :cid', { cid: char.id }) .where('ci.character_id = :cid', { cid: char.id })
.andWhere('ci.equipped = true') .andWhere('ci.equipped = true')
.andWhere('item.type = :type', { type: charItem.item.type }) .andWhere('item.type = :type', { type: charItem.item.type })
.getOne(); .getOne();
if (currentEquipped) { if (currentEquipped) {
currentEquipped.equipped = false; currentEquipped.equipped = false;
await this.charItemRepository.save(currentEquipped); await charItemRepo.save(currentEquipped);
} }
charItem.equipped = true; charItem.equipped = true;
await this.charItemRepository.save(charItem); await charItemRepo.save(charItem);
return { equipped: true, item: charItem }; return { equipped: true, item: charItem };
});
} }
async unequip(slot: 'weapon' | 'armor', user: User) { async unequip(slot: 'weapon' | 'armor', user: User) {

View File

@@ -4,16 +4,19 @@ import {
Column, Column,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Material } from './material.entity'; import { Material } from './material.entity';
@Entity('character_materials') @Entity('character_materials')
@Index(['characterId', 'materialId'])
export class CharacterMaterial { export class CharacterMaterial {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)

View File

@@ -1,6 +1,6 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm'; import { DataSource, MoreThan, Repository } from 'typeorm';
import { Material } from './material.entity'; import { Material } from './material.entity';
import { CharacterMaterial } from './character-material.entity'; import { CharacterMaterial } from './character-material.entity';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
@@ -15,6 +15,7 @@ export class MaterialService {
private readonly charMatRepository: Repository<CharacterMaterial>, private readonly charMatRepository: Repository<CharacterMaterial>,
@InjectRepository(Character) @InjectRepository(Character)
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
private readonly dataSource: DataSource,
) {} ) {}
findAll() { findAll() {
@@ -39,18 +40,27 @@ export class MaterialService {
return this.charMatRepository.save(entry); 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> { async consumeMaterials(characterId: string, ingredients: { materialId: string; quantity: number }[]): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const charMatRepo = manager.getRepository(CharacterMaterial);
for (const ing of ingredients) { for (const ing of ingredients) {
const entry = await this.charMatRepository.findOne({ // SELECT ... FOR UPDATE — lock chaque entrée matériau
where: { characterId, materialId: ing.materialId }, 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) { if (!entry || entry.quantity < ing.quantity) {
throw new BadRequestException('Matériaux insuffisants pour ce craft'); throw new BadRequestException('Matériaux insuffisants pour ce craft');
} }
entry.quantity -= ing.quantity; entry.quantity -= ing.quantity;
await this.charMatRepository.save(entry); await charMatRepo.save(entry);
} }
});
} }
private async getCharacter(user: User): Promise<Character> { private async getCharacter(user: User): Promise<Character> {