feat(sprint5): audit fixes — transactions, indexes, stat distribution, rest, forge cost
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
P0 — Race conditions fixées avec pessimistic_write transactions : combat (double-spend endurance), forge (double upgrade), craft (consumeMaterials atomique), equip (item swap). Forge : coût or (50-1000) + endurance (15) ajouté. Combat : item stat bonuses (force/agilite/intelligence/chance) appliqués. P1 — Features manquantes : POST /api/characters/stats — distribution stat points (avec lock). POST /api/characters/rest — repos auberge (+50% HP, -20 endurance). Vitalité : +10 HP max par point distribué. P2 — Indexes DB ajoutés : character_id sur character_items, character_materials, combat_logs, craft_jobs, player_achievements, community_contributions. Composite (characterId, materialId) sur character_materials. period sur hall_of_fame. achievement_id sur player_achievements. P3 — Cleanup : @nestjs/jwt et pg retirés de package.json.
This commit is contained in:
51
SPRINT5.md
Normal file
51
SPRINT5.md
Normal 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
162
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Achievement } from './achievement.entity';
|
||||
@@ -16,6 +17,7 @@ export class PlayerAchievement {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
@@ -23,6 +25,7 @@ export class PlayerAchievement {
|
||||
character: Character;
|
||||
|
||||
@Column({ name: 'achievement_id' })
|
||||
@Index()
|
||||
achievementId: string;
|
||||
|
||||
@ManyToOne(() => Achievement)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Request } from 'express';
|
||||
import { CharacterService } from './character.service';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { DistributeStatsDto } from './dto/distribute-stats.dto';
|
||||
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@@ -37,4 +38,19 @@ export class CharacterController {
|
||||
getEndurance(@Req() req: Request & { user: User }) {
|
||||
return this.characterService.getEndurance(req.user);
|
||||
}
|
||||
|
||||
@Post('stats')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
distributeStats(
|
||||
@Body() dto: DistributeStatsDto,
|
||||
@Req() req: Request & { user: User },
|
||||
) {
|
||||
return this.characterService.distributeStats(dto, req.user);
|
||||
}
|
||||
|
||||
@Post('rest')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
rest(@Req() req: Request & { user: User }) {
|
||||
return this.characterService.rest(req.user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { DistributeStatsDto } from './dto/distribute-stats.dto';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer
|
||||
const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure
|
||||
const REST_ENDURANCE_COST = 20;
|
||||
const REST_HP_REGEN_RATIO = 0.5; // +50% hpMax
|
||||
|
||||
@Injectable()
|
||||
export class CharacterService {
|
||||
@@ -21,6 +24,7 @@ export class CharacterService {
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
@InjectRepository(LevelThreshold)
|
||||
private readonly levelThresholdRepository: Repository<LevelThreshold>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// Pattern lazy calculation — pas de timer actif
|
||||
@@ -94,4 +98,106 @@ export class CharacterService {
|
||||
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
async distributeStats(dto: DistributeStatsDto, user: User) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const character = await manager
|
||||
.getRepository(Character)
|
||||
.createQueryBuilder('c')
|
||||
.setLock('pessimistic_write')
|
||||
.where('c.user_id = :userId', { userId: user.id })
|
||||
.getOne();
|
||||
|
||||
if (!character) throw new NotFoundException('Aucun personnage trouvé');
|
||||
|
||||
const totalToDistribute =
|
||||
(dto.force ?? 0) + (dto.agilite ?? 0) + (dto.intelligence ?? 0) +
|
||||
(dto.chance ?? 0) + (dto.vitalite ?? 0);
|
||||
|
||||
if (totalToDistribute <= 0) {
|
||||
throw new BadRequestException('Aucun point à distribuer');
|
||||
}
|
||||
|
||||
if (totalToDistribute > (character.statPoints ?? 0)) {
|
||||
throw new BadRequestException(
|
||||
`Points insuffisants (${character.statPoints ?? 0} disponibles, ${totalToDistribute} demandés)`,
|
||||
);
|
||||
}
|
||||
|
||||
character.force += dto.force ?? 0;
|
||||
character.agilite += dto.agilite ?? 0;
|
||||
character.intelligence += dto.intelligence ?? 0;
|
||||
character.chance += dto.chance ?? 0;
|
||||
character.vitalite += dto.vitalite ?? 0;
|
||||
character.statPoints = (character.statPoints ?? 0) - totalToDistribute;
|
||||
|
||||
// Vitalité augmente HP max (+10 par point)
|
||||
const vitaliteAdded = dto.vitalite ?? 0;
|
||||
if (vitaliteAdded > 0) {
|
||||
character.hpMax += vitaliteAdded * 10;
|
||||
character.hpCurrent += vitaliteAdded * 10; // bonus immédiat
|
||||
}
|
||||
|
||||
await manager.save(character);
|
||||
|
||||
return {
|
||||
statPoints: character.statPoints,
|
||||
stats: {
|
||||
force: character.force,
|
||||
agilite: character.agilite,
|
||||
intelligence: character.intelligence,
|
||||
chance: character.chance,
|
||||
vitalite: character.vitalite,
|
||||
},
|
||||
hpMax: character.hpMax,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async rest(user: User) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const character = await manager
|
||||
.getRepository(Character)
|
||||
.createQueryBuilder('c')
|
||||
.setLock('pessimistic_write')
|
||||
.where('c.user_id = :userId', { userId: user.id })
|
||||
.getOne();
|
||||
|
||||
if (!character) throw new NotFoundException('Aucun personnage trouvé');
|
||||
|
||||
if (character.hpCurrent >= character.hpMax) {
|
||||
throw new BadRequestException('PV déjà au maximum');
|
||||
}
|
||||
|
||||
// Calculer endurance
|
||||
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES);
|
||||
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||
|
||||
if (enduranceCurrent < REST_ENDURANCE_COST) {
|
||||
throw new BadRequestException(
|
||||
`Endurance insuffisante (${enduranceCurrent}/${REST_ENDURANCE_COST} requis)`,
|
||||
);
|
||||
}
|
||||
|
||||
const hpBefore = character.hpCurrent;
|
||||
character.hpCurrent = Math.min(
|
||||
character.hpMax,
|
||||
character.hpCurrent + Math.floor(character.hpMax * REST_HP_REGEN_RATIO),
|
||||
);
|
||||
character.enduranceSaved = enduranceCurrent - REST_ENDURANCE_COST;
|
||||
character.lastEnduranceTs = new Date();
|
||||
|
||||
await manager.save(character);
|
||||
|
||||
return {
|
||||
hpBefore,
|
||||
hpAfter: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
healed: character.hpCurrent - hpBefore,
|
||||
enduranceCurrent: character.enduranceSaved,
|
||||
enduranceMax: character.enduranceMax,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
18
src/character/dto/distribute-stats.dto.ts
Normal file
18
src/character/dto/distribute-stats.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IsInt, Min, IsOptional } from 'class-validator';
|
||||
|
||||
export class DistributeStatsDto {
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
force?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
agilite?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
intelligence?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
chance?: number = 0;
|
||||
|
||||
@IsInt() @Min(0) @IsOptional()
|
||||
vitalite?: number = 0;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Monster } from '../monster/monster.entity';
|
||||
@@ -15,6 +16,7 @@ export class CombatLog {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Monster } from '../monster/monster.entity';
|
||||
@@ -35,18 +35,25 @@ 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);
|
||||
|
||||
// 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)
|
||||
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||
const recharge = Math.floor(elapsedMinutes / 6);
|
||||
@@ -72,15 +79,21 @@ export class CombatService {
|
||||
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||
: 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
|
||||
const playerStats: CombatantStats = {
|
||||
name: character.name,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
force: character.force,
|
||||
agilite: character.agilite,
|
||||
intelligence: character.intelligence,
|
||||
chance: character.chance,
|
||||
force: character.force + itemForceBonus,
|
||||
agilite: character.agilite + itemAgiliteBonus,
|
||||
intelligence: character.intelligence + itemIntelligenceBonus,
|
||||
chance: character.chance + itemChanceBonus,
|
||||
attack: weaponAttack,
|
||||
defense: armorDefense,
|
||||
attackType: dto.attackType,
|
||||
@@ -93,7 +106,7 @@ export class CombatService {
|
||||
force: 0,
|
||||
agilite: 0,
|
||||
intelligence: 0,
|
||||
chance: 0, // pas de crit/esquive pour les monstres Sprint 2
|
||||
chance: 0,
|
||||
attack: monster.attack,
|
||||
defense: monster.defense,
|
||||
attackType: monster.attackType,
|
||||
@@ -121,6 +134,7 @@ export class CombatService {
|
||||
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
|
||||
@@ -130,49 +144,13 @@ export class CombatService {
|
||||
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)
|
||||
// Sauvegarder le personnage (dans la transaction)
|
||||
character.hpCurrent = newHp;
|
||||
character.enduranceSaved = newEnduranceSaved;
|
||||
character.lastEnduranceTs = new Date();
|
||||
await this.characterRepository.save(character);
|
||||
await manager.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
|
||||
// Apply XP boost from community (dans la transaction)
|
||||
if (result.winner === 'player') {
|
||||
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
|
||||
if (xpBoost > 1.0) {
|
||||
@@ -182,12 +160,12 @@ export class CombatService {
|
||||
character.xp = boosted.newXp;
|
||||
character.level = boosted.newLevel;
|
||||
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;
|
||||
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||||
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||||
@@ -205,7 +183,26 @@ export class CombatService {
|
||||
goldEarned: result.goldEarned,
|
||||
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
|
||||
const summaryParts: string[] = [];
|
||||
@@ -243,11 +240,12 @@ export class CombatService {
|
||||
gold: character.gold,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat
|
||||
enduranceCurrent: character.enduranceSaved,
|
||||
enduranceMax: character.enduranceMax,
|
||||
statPoints: character.statPoints ?? 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(user: User) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { CommunityGoal } from './community-goal.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
@@ -23,6 +24,7 @@ export class CommunityContribution {
|
||||
communityGoal: CommunityGoal;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Recipe } from './recipe.entity';
|
||||
@@ -15,6 +16,7 @@ export class CraftJob {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CharacterItem } from '../item/character-item.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
const MAX_FORGE_LEVEL = 5;
|
||||
const FORGE_BONUS_PER_LEVEL = 2; // +2 attack (weapon) ou +2 defense (armor) par niveau affiché
|
||||
const FORGE_BONUS_PER_LEVEL = 2;
|
||||
|
||||
// Coût en or par niveau cible
|
||||
const FORGE_GOLD_COST: Record<number, number> = {
|
||||
1: 50,
|
||||
2: 100,
|
||||
3: 250,
|
||||
4: 500,
|
||||
5: 1000,
|
||||
};
|
||||
|
||||
const FORGE_ENDURANCE_COST = 15;
|
||||
|
||||
// Risque d'échec par niveau cible (GDD exact)
|
||||
const FORGE_FAIL_CHANCE: Record<number, number> = {
|
||||
@@ -26,49 +37,88 @@ export class ForgeService {
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async upgradeItem(charItemId: string, user: User) {
|
||||
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
|
||||
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 },
|
||||
});
|
||||
// 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 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 success = Math.random() >= failChance;
|
||||
|
||||
if (success) {
|
||||
charItem.forgeLevel = targetLevel;
|
||||
await this.charItemRepository.save(charItem);
|
||||
await manager.save(charItem);
|
||||
|
||||
// Emit achievement & community events
|
||||
this.eventEmitter.emit('achievement.check', {
|
||||
characterId: char.id,
|
||||
type: 'forge_upgrades',
|
||||
increment: 1,
|
||||
characterId: char.id, type: 'forge_upgrades', increment: 1,
|
||||
});
|
||||
this.eventEmitter.emit('community.contribute', {
|
||||
characterId: char.id,
|
||||
type: 'total_forge_upgrades',
|
||||
increment: 1,
|
||||
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,
|
||||
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,
|
||||
forgeLevel: charItem.forgeLevel,
|
||||
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.`,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
|
||||
@@ -20,6 +21,7 @@ export class HallOfFame {
|
||||
character: Character;
|
||||
|
||||
@Column({ length: 7 })
|
||||
@Index()
|
||||
period: string; // 'YYYY-MM'
|
||||
|
||||
@Column()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Item } from './item.entity';
|
||||
@@ -15,6 +16,7 @@ export class CharacterItem {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Item } from './item.entity';
|
||||
import { CharacterItem } from './character-item.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
@@ -15,6 +15,7 @@ export class ItemService {
|
||||
private readonly charItemRepository: Repository<CharacterItem>,
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
@@ -30,29 +31,39 @@ export class ItemService {
|
||||
}
|
||||
|
||||
async equip(charItemId: string, user: User) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const char = await this.getCharacter(user);
|
||||
const charItem = await this.charItemRepository.findOne({
|
||||
where: { id: charItemId, characterId: char.id },
|
||||
});
|
||||
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 (type) si existe
|
||||
const currentEquipped = await this.charItemRepository
|
||||
// Déséquiper l'item du même slot
|
||||
const currentEquipped = await charItemRepo
|
||||
.createQueryBuilder('ci')
|
||||
.setLock('pessimistic_write')
|
||||
.leftJoinAndSelect('ci.item', 'item')
|
||||
.where('ci.characterId = :cid', { cid: char.id })
|
||||
.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 this.charItemRepository.save(currentEquipped);
|
||||
await charItemRepo.save(currentEquipped);
|
||||
}
|
||||
|
||||
charItem.equipped = true;
|
||||
await this.charItemRepository.save(charItem);
|
||||
await charItemRepo.save(charItem);
|
||||
return { equipped: true, item: charItem };
|
||||
});
|
||||
}
|
||||
|
||||
async unequip(slot: 'weapon' | 'armor', user: User) {
|
||||
|
||||
@@ -4,16 +4,19 @@ import {
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Material } from './material.entity';
|
||||
|
||||
@Entity('character_materials')
|
||||
@Index(['characterId', 'materialId'])
|
||||
export class CharacterMaterial {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThan, Repository } from 'typeorm';
|
||||
import { DataSource, MoreThan, Repository } from 'typeorm';
|
||||
import { Material } from './material.entity';
|
||||
import { CharacterMaterial } from './character-material.entity';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
@@ -15,6 +15,7 @@ export class MaterialService {
|
||||
private readonly charMatRepository: Repository<CharacterMaterial>,
|
||||
@InjectRepository(Character)
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
@@ -39,18 +40,27 @@ export class MaterialService {
|
||||
return this.charMatRepository.save(entry);
|
||||
}
|
||||
|
||||
// Appelé par CraftService pour consommer les ingrédients
|
||||
// Appelé par CraftService — consommation atomique en transaction
|
||||
async consumeMaterials(characterId: string, ingredients: { materialId: string; quantity: number }[]): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const charMatRepo = manager.getRepository(CharacterMaterial);
|
||||
|
||||
for (const ing of ingredients) {
|
||||
const entry = await this.charMatRepository.findOne({
|
||||
where: { characterId, materialId: ing.materialId },
|
||||
});
|
||||
// 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 this.charMatRepository.save(entry);
|
||||
await charMatRepo.save(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async getCharacter(user: User): Promise<Character> {
|
||||
|
||||
Reference in New Issue
Block a user