feat(sprint3): items + forge + craft + loot — équipement, artisanat lazy-calc, forge risque GDD
This commit is contained in:
244
SPRINT3.md
Normal file
244
SPRINT3.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# TetaRdPG — Brief Sprint 3
|
||||||
|
|
||||||
|
> Statut : 🔄 En cours
|
||||||
|
> Objectif : Items + Inventaire + Artisanat (Craft) + Forge
|
||||||
|
> Stack : NestJS · PostgreSQL · TypeORM (synchronize dev)
|
||||||
|
> Prérequis : Sprint 2 livré ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Sprint 3
|
||||||
|
|
||||||
|
### ✅ In scope
|
||||||
|
|
||||||
|
- Entité `items` (armes + armures) avec bonus stats
|
||||||
|
- Inventaire joueur (`character_items`) — possession + équipement actif
|
||||||
|
- Intégration combat : `player.attack` et `player.defense` depuis l'équipement équipé
|
||||||
|
- Entité `materials` + inventaire joueur (`character_materials`) — loot post-combat
|
||||||
|
- Entité `recipes` + ingrédients en jsonb
|
||||||
|
- Artisanat (`craft_jobs`) — lazy calc timer (même pattern que endurance)
|
||||||
|
- Forge — amélioration item niveau 1–5 avec risque croissant
|
||||||
|
- Seeds : 5 items de base, 5 matériaux, 3 recettes
|
||||||
|
- API : voir section dédiée
|
||||||
|
|
||||||
|
### ❌ Out of scope
|
||||||
|
|
||||||
|
- Boutique (achat/vente) — Sprint 4
|
||||||
|
- Bonus de sets d'équipement — Sprint 4
|
||||||
|
- TetardCoin (accélération craft, forge garantie) — sprint monétisation
|
||||||
|
- Twitch, PvP, guildes
|
||||||
|
- Frontend React
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Décisions de design (game-designer)
|
||||||
|
|
||||||
|
| Décision | Valeur | Justification |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| Slots équipement | weapon + armor | Simplifié Sprint 3 — casque/bottes Sprint 4 |
|
||||||
|
| Player.attack en combat | `char.weapon?.attackBonus ?? 0` | Remplace attack=0 Sprint 2 |
|
||||||
|
| Player.defense en combat | `char.armor?.defenseBonus ?? 0` | Remplace defense=0 Sprint 2 |
|
||||||
|
| Loot drop | 40% de chance après victoire | 1 matériau aléatoire parmi ceux du monstre |
|
||||||
|
| Craft timer | lazy calc : `startedAt + durationMs` | Même pattern endurance — zéro job schedulé |
|
||||||
|
| Forge risque | Niv.1–2 : 0% | 3 : 20% | 4 : 30% | 5 : 40% | GDD exact |
|
||||||
|
| Forge succès garanti | 12 TetardCoin (non implémenté Sprint 3) | Placeholder, champ `forcedSuccess` |
|
||||||
|
| Forge échec | item inchangé, pas de coût matériaux Sprint 3 | coût matériaux forge = Sprint 4 |
|
||||||
|
| Durée craft | court: 15s (dev) / long: 60s (dev) | Valeurs réelles en prod : 15min–2h |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schéma DB
|
||||||
|
|
||||||
|
### `items`
|
||||||
|
```
|
||||||
|
id uuid PK
|
||||||
|
name varchar(100)
|
||||||
|
description text
|
||||||
|
type varchar(20) -- 'weapon' | 'armor'
|
||||||
|
rarity varchar(20) -- 'common' | 'rare' | 'epic' | 'legendary'
|
||||||
|
attack_bonus int default 0
|
||||||
|
defense_bonus int default 0
|
||||||
|
force_bonus int default 0
|
||||||
|
agilite_bonus int default 0
|
||||||
|
intelligence_bonus int default 0
|
||||||
|
chance_bonus int default 0
|
||||||
|
vitalite_bonus int default 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `character_items`
|
||||||
|
```
|
||||||
|
id uuid PK
|
||||||
|
character_id uuid FK characters
|
||||||
|
item_id uuid FK items
|
||||||
|
forge_level int default 0 -- 0 = non forgé
|
||||||
|
equipped boolean default false
|
||||||
|
acquired_at timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
### `materials`
|
||||||
|
```
|
||||||
|
id uuid PK
|
||||||
|
name varchar(100)
|
||||||
|
description text
|
||||||
|
rarity varchar(20)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `character_materials`
|
||||||
|
```
|
||||||
|
id uuid PK
|
||||||
|
character_id uuid FK characters
|
||||||
|
material_id uuid FK materials
|
||||||
|
quantity int default 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `recipes`
|
||||||
|
```
|
||||||
|
id uuid PK
|
||||||
|
name varchar(100)
|
||||||
|
result_item_id uuid FK items
|
||||||
|
craft_duration_seconds int
|
||||||
|
endurance_cost int
|
||||||
|
ingredients jsonb -- [{ materialId, quantity }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `craft_jobs`
|
||||||
|
```
|
||||||
|
id uuid PK
|
||||||
|
character_id uuid FK characters
|
||||||
|
recipe_id uuid FK recipes
|
||||||
|
started_at timestamp
|
||||||
|
completed_at timestamp -- lazy : startedAt + duration
|
||||||
|
collected boolean default false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeds
|
||||||
|
|
||||||
|
### Items (5)
|
||||||
|
|
||||||
|
| Nom | Type | Rareté | Attack | Defense | Notes |
|
||||||
|
|-----|------|--------|--------|---------|-------|
|
||||||
|
| Bâton de Roseau | weapon | common | 3 | 0 | Arme de départ |
|
||||||
|
| Dague Rouillée | weapon | common | 5 | 0 | |
|
||||||
|
| Épée Courte | weapon | rare | 9 | 0 | |
|
||||||
|
| Gilet de Cuir | armor | common | 0 | 3 | |
|
||||||
|
| Cotte de Mailles | armor | rare | 0 | 7 | |
|
||||||
|
|
||||||
|
### Matériaux (5)
|
||||||
|
|
||||||
|
| Nom | Rareté | Sources |
|
||||||
|
|-----|--------|---------|
|
||||||
|
| Bave de Têtard | common | Têtard Vase |
|
||||||
|
| Écailles de Grenouille | common | Grenouille Boueuse |
|
||||||
|
| Venin de Serpent | rare | Serpent des Marais |
|
||||||
|
| Spores Vénéneuses | rare | Champi Vénéneux |
|
||||||
|
| Fragment de Boue | common | Golem de Boue |
|
||||||
|
|
||||||
|
### Recettes (3)
|
||||||
|
|
||||||
|
| Nom | Résultat | Durée (dev) | Endurance | Ingrédients |
|
||||||
|
|-----|----------|-------------|-----------|-------------|
|
||||||
|
| Forge Bâton Renforcé | Bâton de Roseau+? | 15s | 5 | 2× Bave de Têtard |
|
||||||
|
| Craft Dague | Dague Rouillée | 15s | 8 | 3× Bave + 1× Écaille |
|
||||||
|
| Craft Gilet de Cuir | Gilet de Cuir | 30s | 10 | 3× Écaille + 2× Fragment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Sprint 3
|
||||||
|
|
||||||
|
```
|
||||||
|
# Items
|
||||||
|
GET /api/items → liste tous les items (catalogue)
|
||||||
|
GET /api/items/inventory → inventaire du personnage connecté
|
||||||
|
POST /api/items/equip/:itemId → équiper un item (character_items.id)
|
||||||
|
POST /api/items/unequip/:slot → déséquiper un slot (weapon|armor)
|
||||||
|
|
||||||
|
# Materials
|
||||||
|
GET /api/materials → catalogue matériaux
|
||||||
|
GET /api/materials/inventory → matériaux du personnage connecté
|
||||||
|
|
||||||
|
# Craft
|
||||||
|
GET /api/craft/recipes → liste recettes
|
||||||
|
POST /api/craft/start → { recipeId } → lance le craft
|
||||||
|
GET /api/craft/active → craft en cours (lazy status)
|
||||||
|
POST /api/craft/collect/:jobId → collecter si terminé
|
||||||
|
|
||||||
|
# Forge
|
||||||
|
POST /api/forge/upgrade → { characterItemId } → tente amélioration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture modules
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── item/
|
||||||
|
│ ├── item.entity.ts
|
||||||
|
│ ├── character-item.entity.ts
|
||||||
|
│ ├── item.module.ts
|
||||||
|
│ ├── item.service.ts
|
||||||
|
│ └── item.controller.ts
|
||||||
|
├── material/
|
||||||
|
│ ├── material.entity.ts
|
||||||
|
│ ├── character-material.entity.ts
|
||||||
|
│ ├── material.module.ts
|
||||||
|
│ ├── material.service.ts
|
||||||
|
│ └── material.controller.ts
|
||||||
|
├── craft/
|
||||||
|
│ ├── recipe.entity.ts
|
||||||
|
│ ├── craft-job.entity.ts
|
||||||
|
│ ├── craft.module.ts
|
||||||
|
│ ├── craft.service.ts
|
||||||
|
│ └── craft.controller.ts
|
||||||
|
├── forge/
|
||||||
|
│ ├── forge.module.ts
|
||||||
|
│ ├── forge.service.ts
|
||||||
|
│ └── forge.controller.ts
|
||||||
|
└── database/
|
||||||
|
└── items-seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intégration combat (CombatEngine)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Dans CombatService.startCombat() — charger l'équipement
|
||||||
|
const weaponBonus = char.equippedWeapon?.item.attackBonus ?? 0;
|
||||||
|
const armorBonus = char.equippedArmor?.item.defenseBonus ?? 0;
|
||||||
|
|
||||||
|
const playerStats: CombatantStats = {
|
||||||
|
...
|
||||||
|
attack: weaponBonus, // était 0 Sprint 2
|
||||||
|
defense: armorBonus, // était 0 Sprint 2
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Loot post-victoire :
|
||||||
|
```typescript
|
||||||
|
if (result.winner === 'player' && Math.random() < 0.4) {
|
||||||
|
// créditer 1 matériau aléatoire dans character_materials
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères de validation integrator
|
||||||
|
|
||||||
|
- [ ] `GET /api/items` → 5 items seedés
|
||||||
|
- [ ] `GET /api/materials` → 5 matériaux seedés
|
||||||
|
- [ ] `POST /api/items/equip/:id` → item équipé, character mis à jour
|
||||||
|
- [ ] Combat avec arme équipée → player.attack = weaponBonus
|
||||||
|
- [ ] Victoire combat → chance de loot matériau (40%)
|
||||||
|
- [ ] `GET /api/materials/inventory` → quantité augmentée après loot
|
||||||
|
- [ ] `GET /api/craft/recipes` → 3 recettes disponibles
|
||||||
|
- [ ] `POST /api/craft/start` → job créé, endurance déduite
|
||||||
|
- [ ] `GET /api/craft/active` → status `pending` | `ready` | `none`
|
||||||
|
- [ ] `POST /api/craft/collect/:jobId` → item ajouté à l'inventaire
|
||||||
|
- [ ] Collect avant fin → 400 "not ready yet"
|
||||||
|
- [ ] `POST /api/forge/upgrade` → niveau 1 : succès garanti
|
||||||
|
- [ ] Forge niveau 3 → 20% chance échec (matériaux déduits, forgeLvl inchangé)
|
||||||
|
- [ ] Sans cookie → 401 sur toutes les routes protégées
|
||||||
|
- [ ] Endurance insuffisante pour craft → 400
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
||||||
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
|
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
|
||||||
|
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
|
||||||
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { CharacterModule } from './character/character.module';
|
import { CharacterModule } from './character/character.module';
|
||||||
import { MonsterModule } from './monster/monster.module';
|
import { MonsterModule } from './monster/monster.module';
|
||||||
import { CombatModule } from './combat/combat.module';
|
import { CombatModule } from './combat/combat.module';
|
||||||
|
import { ItemModule } from './item/item.module';
|
||||||
|
import { MaterialModule } from './material/material.module';
|
||||||
|
import { CraftModule } from './craft/craft.module';
|
||||||
|
import { ForgeModule } from './forge/forge.module';
|
||||||
import { HealthController } from './common/health.controller';
|
import { HealthController } from './common/health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -35,6 +39,10 @@ import { HealthController } from './common/health.controller';
|
|||||||
CharacterModule,
|
CharacterModule,
|
||||||
MonsterModule,
|
MonsterModule,
|
||||||
CombatModule,
|
CombatModule,
|
||||||
|
ItemModule,
|
||||||
|
MaterialModule,
|
||||||
|
CraftModule,
|
||||||
|
ForgeModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import { CombatService } from './combat.service';
|
|||||||
import { CombatController } from './combat.controller';
|
import { CombatController } from './combat.controller';
|
||||||
import { MonsterModule } from '../monster/monster.module';
|
import { MonsterModule } from '../monster/monster.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ItemModule } from '../item/item.module';
|
||||||
|
import { MaterialModule } from '../material/material.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Character, CombatLog]),
|
TypeOrmModule.forFeature([Character, CombatLog]),
|
||||||
MonsterModule,
|
MonsterModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
ItemModule,
|
||||||
|
MaterialModule,
|
||||||
],
|
],
|
||||||
controllers: [CombatController],
|
controllers: [CombatController],
|
||||||
providers: [CombatService],
|
providers: [CombatService],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MonsterService } from '../monster/monster.service';
|
|||||||
import { CombatLog } from './combat-log.entity';
|
import { CombatLog } from './combat-log.entity';
|
||||||
import { StartCombatDto } from './dto/start-combat.dto';
|
import { StartCombatDto } from './dto/start-combat.dto';
|
||||||
import { User } from '../user/user.entity';
|
import { User } from '../user/user.entity';
|
||||||
|
import { ItemService } from '../item/item.service';
|
||||||
|
import { MaterialService } from '../material/material.service';
|
||||||
import {
|
import {
|
||||||
resolveCombat,
|
resolveCombat,
|
||||||
applyXpGain,
|
applyXpGain,
|
||||||
@@ -27,6 +29,8 @@ export class CombatService {
|
|||||||
@InjectRepository(CombatLog)
|
@InjectRepository(CombatLog)
|
||||||
private readonly combatLogRepository: Repository<CombatLog>,
|
private readonly combatLogRepository: Repository<CombatLog>,
|
||||||
private readonly monsterService: MonsterService,
|
private readonly monsterService: MonsterService,
|
||||||
|
private readonly itemService: ItemService,
|
||||||
|
private readonly materialService: MaterialService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async startCombat(dto: StartCombatDto, user: User) {
|
async startCombat(dto: StartCombatDto, user: User) {
|
||||||
@@ -54,6 +58,16 @@ export class CombatService {
|
|||||||
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charger l'équipement actif du personnage
|
||||||
|
const equipped = await this.itemService.getEquippedItems(character.id);
|
||||||
|
const FORGE_BONUS_PER_LEVEL = 2;
|
||||||
|
const weaponAttack = equipped.weapon
|
||||||
|
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||||
|
: 0;
|
||||||
|
const armorDefense = equipped.armor
|
||||||
|
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
|
||||||
|
: 0;
|
||||||
|
|
||||||
// Construire les stats des combattants
|
// Construire les stats des combattants
|
||||||
const playerStats: CombatantStats = {
|
const playerStats: CombatantStats = {
|
||||||
name: character.name,
|
name: character.name,
|
||||||
@@ -63,8 +77,8 @@ export class CombatService {
|
|||||||
agilite: character.agilite,
|
agilite: character.agilite,
|
||||||
intelligence: character.intelligence,
|
intelligence: character.intelligence,
|
||||||
chance: character.chance,
|
chance: character.chance,
|
||||||
attack: 0, // pas d'arme Sprint 2
|
attack: weaponAttack,
|
||||||
defense: 0, // pas d'armure Sprint 2
|
defense: armorDefense,
|
||||||
attackType: dto.attackType,
|
attackType: dto.attackType,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +132,13 @@ export class CombatService {
|
|||||||
character.lastEnduranceTs = new Date();
|
character.lastEnduranceTs = new Date();
|
||||||
await this.characterRepository.save(character);
|
await this.characterRepository.save(character);
|
||||||
|
|
||||||
|
// Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id
|
||||||
|
let lootMaterial: { name: string; quantity: number } | null = null;
|
||||||
|
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
|
||||||
|
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
|
||||||
|
lootMaterial = { name: 'matériau', quantity: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
// Persister le log
|
// Persister le log
|
||||||
const combatLog = this.combatLogRepository.create({
|
const combatLog = this.combatLogRepository.create({
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
@@ -144,6 +165,10 @@ export class CombatService {
|
|||||||
if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`);
|
if (goldLost > 0) summaryParts.push(`−${goldLost} Or perdu.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lootMaterial) {
|
||||||
|
summaryParts.push(`Loot : 1 matériau obtenu !`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
winner: result.winner,
|
winner: result.winner,
|
||||||
rounds: result.rounds,
|
rounds: result.rounds,
|
||||||
@@ -155,6 +180,7 @@ export class CombatService {
|
|||||||
levelUp: levelUpData.levelsGained > 0,
|
levelUp: levelUpData.levelsGained > 0,
|
||||||
newLevel: levelUpData.newLevel,
|
newLevel: levelUpData.newLevel,
|
||||||
statPointsGained: levelUpData.statPointsGained,
|
statPointsGained: levelUpData.statPointsGained,
|
||||||
|
loot: lootMaterial,
|
||||||
},
|
},
|
||||||
character: {
|
character: {
|
||||||
level: character.level,
|
level: character.level,
|
||||||
|
|||||||
39
src/craft/craft-job.entity.ts
Normal file
39
src/craft/craft-job.entity.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { Recipe } from './recipe.entity';
|
||||||
|
|
||||||
|
@Entity('craft_jobs')
|
||||||
|
export class CraftJob {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id' })
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character)
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character: Character;
|
||||||
|
|
||||||
|
@Column({ name: 'recipe_id' })
|
||||||
|
recipeId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Recipe, { eager: true })
|
||||||
|
@JoinColumn({ name: 'recipe_id' })
|
||||||
|
recipe: Recipe;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'started_at' })
|
||||||
|
startedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamp' })
|
||||||
|
completedAt: Date;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
collected: boolean;
|
||||||
|
}
|
||||||
39
src/craft/craft.controller.ts
Normal file
39
src/craft/craft.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
import { CraftService } from './craft.service';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
class StartCraftDto {
|
||||||
|
@IsUUID()
|
||||||
|
recipeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('craft')
|
||||||
|
export class CraftController {
|
||||||
|
constructor(private readonly craftService: CraftService) {}
|
||||||
|
|
||||||
|
@Get('recipes')
|
||||||
|
findRecipes() {
|
||||||
|
return this.craftService.findAllRecipes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('start')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
start(@Body() dto: StartCraftDto, @Req() req: Request & { user: User }) {
|
||||||
|
return this.craftService.startCraft(dto.recipeId, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('active')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
getActive(@Req() req: Request & { user: User }) {
|
||||||
|
return this.craftService.getActiveJob(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('collect/:jobId')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
collect(@Param('jobId') jobId: string, @Req() req: Request & { user: User }) {
|
||||||
|
return this.craftService.collectCraft(jobId, req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/craft/craft.module.ts
Normal file
22
src/craft/craft.module.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Recipe } from './recipe.entity';
|
||||||
|
import { CraftJob } from './craft-job.entity';
|
||||||
|
import { CraftService } from './craft.service';
|
||||||
|
import { CraftController } from './craft.controller';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ItemModule } from '../item/item.module';
|
||||||
|
import { MaterialModule } from '../material/material.module';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Recipe, CraftJob, Character]),
|
||||||
|
AuthModule,
|
||||||
|
ItemModule,
|
||||||
|
MaterialModule,
|
||||||
|
],
|
||||||
|
controllers: [CraftController],
|
||||||
|
providers: [CraftService],
|
||||||
|
})
|
||||||
|
export class CraftModule {}
|
||||||
128
src/craft/craft.service.ts
Normal file
128
src/craft/craft.service.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Recipe } from './recipe.entity';
|
||||||
|
import { CraftJob } from './craft-job.entity';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { ItemService } from '../item/item.service';
|
||||||
|
import { MaterialService } from '../material/material.service';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CraftService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Recipe)
|
||||||
|
private readonly recipeRepository: Repository<Recipe>,
|
||||||
|
@InjectRepository(CraftJob)
|
||||||
|
private readonly craftJobRepository: Repository<CraftJob>,
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
private readonly itemService: ItemService,
|
||||||
|
private readonly materialService: MaterialService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findAllRecipes() {
|
||||||
|
return this.recipeRepository.find({ order: { name: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async startCraft(recipeId: string, user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
const recipe = await this.recipeRepository.findOne({ where: { id: recipeId } });
|
||||||
|
if (!recipe) throw new NotFoundException('Recette introuvable');
|
||||||
|
|
||||||
|
// Vérifier qu'aucun craft actif
|
||||||
|
const activeCraft = await this.craftJobRepository.findOne({
|
||||||
|
where: { characterId: char.id, collected: false },
|
||||||
|
});
|
||||||
|
if (activeCraft) throw new BadRequestException('Un craft est déjà en cours');
|
||||||
|
|
||||||
|
// Calculer endurance actuelle (lazy pattern)
|
||||||
|
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 < recipe.enduranceCost) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Endurance insuffisante (${enduranceCurrent}/${recipe.enduranceCost} requis)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consommer les matériaux (vérifie la dispo et déduit)
|
||||||
|
await this.materialService.consumeMaterials(char.id, recipe.ingredients);
|
||||||
|
|
||||||
|
// Déduire l'endurance
|
||||||
|
char.enduranceSaved = enduranceCurrent - recipe.enduranceCost;
|
||||||
|
char.lastEnduranceTs = new Date();
|
||||||
|
await this.characterRepository.save(char);
|
||||||
|
|
||||||
|
// Créer le job (lazy timer)
|
||||||
|
const startedAt = new Date();
|
||||||
|
const completedAt = new Date(startedAt.getTime() + recipe.craftDurationSeconds * 1000);
|
||||||
|
const job = this.craftJobRepository.create({
|
||||||
|
characterId: char.id,
|
||||||
|
recipeId: recipe.id,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
collected: false,
|
||||||
|
});
|
||||||
|
await this.craftJobRepository.save(job);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
recipe: recipe.name,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
remainingSeconds: recipe.craftDurationSeconds,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveJob(user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
const job = await this.craftJobRepository.findOne({
|
||||||
|
where: { characterId: char.id, collected: false },
|
||||||
|
});
|
||||||
|
if (!job) return { status: 'none' };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const remaining = Math.max(0, Math.floor((job.completedAt.getTime() - now.getTime()) / 1000));
|
||||||
|
return {
|
||||||
|
status: now >= job.completedAt ? 'ready' : 'pending',
|
||||||
|
jobId: job.id,
|
||||||
|
recipe: job.recipe.name,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
remainingSeconds: remaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectCraft(jobId: string, user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
const job = await this.craftJobRepository.findOne({
|
||||||
|
where: { id: jobId, characterId: char.id, collected: false },
|
||||||
|
});
|
||||||
|
if (!job) throw new NotFoundException('Craft introuvable');
|
||||||
|
|
||||||
|
const remaining = Math.ceil((job.completedAt.getTime() - Date.now()) / 1000);
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new BadRequestException(`Craft non terminé — encore ${remaining}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter l'item crafté à l'inventaire
|
||||||
|
const charItem = await this.itemService.addItemToInventory(char.id, job.recipe.resultItemId);
|
||||||
|
|
||||||
|
job.collected = true;
|
||||||
|
await this.craftJobRepository.save(job);
|
||||||
|
|
||||||
|
return {
|
||||||
|
collected: true,
|
||||||
|
item: charItem.item,
|
||||||
|
message: `${job.recipe.resultItem.name} ajouté à l'inventaire !`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCharacter(user: User): Promise<Character> {
|
||||||
|
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
|
||||||
|
if (!char) throw new BadRequestException('Aucun personnage trouvé');
|
||||||
|
return char;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/craft/recipe.entity.ts
Normal file
32
src/craft/recipe.entity.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { Item } from '../item/item.entity';
|
||||||
|
|
||||||
|
export interface RecipeIngredient {
|
||||||
|
materialId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('recipes')
|
||||||
|
export class Recipe {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'result_item_id' })
|
||||||
|
resultItemId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Item, { eager: true })
|
||||||
|
@JoinColumn({ name: 'result_item_id' })
|
||||||
|
resultItem: Item;
|
||||||
|
|
||||||
|
@Column({ name: 'craft_duration_seconds' })
|
||||||
|
craftDurationSeconds: number;
|
||||||
|
|
||||||
|
@Column({ name: 'endurance_cost' })
|
||||||
|
enduranceCost: number;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb' })
|
||||||
|
ingredients: RecipeIngredient[];
|
||||||
|
}
|
||||||
144
src/database/items-seed.ts
Normal file
144
src/database/items-seed.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Item } from '../item/item.entity';
|
||||||
|
import { Material } from '../material/material.entity';
|
||||||
|
import { Recipe } from '../craft/recipe.entity';
|
||||||
|
import { Monster } from '../monster/monster.entity';
|
||||||
|
|
||||||
|
const dataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
|
||||||
|
entities: [Item, Material, Recipe, Monster],
|
||||||
|
synchronize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ name: 'Bâton de Roseau', description: 'Un bâton taillé dans un roseau des marais.', type: 'weapon' as const, rarity: 'common' as const, attackBonus: 3 },
|
||||||
|
{ name: 'Dague Rouillée', description: 'Une dague usée mais encore tranchante.', type: 'weapon' as const, rarity: 'common' as const, attackBonus: 5 },
|
||||||
|
{ name: 'Épée Courte', description: 'Une épée courte bien équilibrée.', type: 'weapon' as const, rarity: 'rare' as const, attackBonus: 9 },
|
||||||
|
{ name: 'Gilet de Cuir', description: 'Un gilet de cuir tanné offrant une protection basique.', type: 'armor' as const, rarity: 'common' as const, defenseBonus: 3 },
|
||||||
|
{ name: 'Cotte de Mailles', description: 'Une cotte de mailles robuste.', type: 'armor' as const, rarity: 'rare' as const, defenseBonus: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MATERIALS = [
|
||||||
|
{ name: 'Bave de Têtard', description: 'Substance visqueuse sécrétée par les têtards vases.', rarity: 'common' as const },
|
||||||
|
{ name: 'Écailles de Grenouille', description: 'Écailles dures et brillantes de grenouilles boueuses.', rarity: 'common' as const },
|
||||||
|
{ name: 'Venin de Serpent', description: 'Venin concentré extrait d\'un serpent des marais.', rarity: 'rare' as const },
|
||||||
|
{ name: 'Spores Vénéneuses', description: 'Spores toxiques récoltées sur les champi vénéneux.', rarity: 'rare' as const },
|
||||||
|
{ name: 'Fragment de Boue', description: 'Éclat de boue cristallisée prélevé sur un golem.', rarity: 'common' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Loot mapping : monster name → material name
|
||||||
|
const MONSTER_LOOT: Record<string, string> = {
|
||||||
|
'Têtard Vase': 'Bave de Têtard',
|
||||||
|
'Grenouille Boueuse': 'Écailles de Grenouille',
|
||||||
|
'Serpent des Marais': 'Venin de Serpent',
|
||||||
|
'Champi Vénéneux': 'Spores Vénéneuses',
|
||||||
|
'Golem de Boue': 'Fragment de Boue',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
await dataSource.initialize();
|
||||||
|
console.log('DB connectée');
|
||||||
|
|
||||||
|
const itemRepo = dataSource.getRepository(Item);
|
||||||
|
const materialRepo = dataSource.getRepository(Material);
|
||||||
|
const recipeRepo = dataSource.getRepository(Recipe);
|
||||||
|
const monsterRepo = dataSource.getRepository(Monster);
|
||||||
|
|
||||||
|
// Seed matériaux
|
||||||
|
const materialMap: Record<string, string> = {};
|
||||||
|
for (const data of MATERIALS) {
|
||||||
|
let mat = await materialRepo.findOne({ where: { name: data.name } });
|
||||||
|
if (!mat) {
|
||||||
|
mat = await materialRepo.save(materialRepo.create(data));
|
||||||
|
console.log(`✅ Matériau "${data.name}" seedé`);
|
||||||
|
} else {
|
||||||
|
console.log(`⏭ Matériau "${data.name}" déjà présent`);
|
||||||
|
}
|
||||||
|
materialMap[data.name] = mat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed items
|
||||||
|
const itemMap: Record<string, string> = {};
|
||||||
|
for (const data of ITEMS) {
|
||||||
|
let item = await itemRepo.findOne({ where: { name: data.name } });
|
||||||
|
if (!item) {
|
||||||
|
item = await itemRepo.save(itemRepo.create(data));
|
||||||
|
console.log(`✅ Item "${data.name}" seedé`);
|
||||||
|
} else {
|
||||||
|
console.log(`⏭ Item "${data.name}" déjà présent`);
|
||||||
|
}
|
||||||
|
itemMap[data.name] = item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed recettes
|
||||||
|
const RECIPES = [
|
||||||
|
{
|
||||||
|
name: 'Craft Dague Rouillée',
|
||||||
|
resultItemName: 'Dague Rouillée',
|
||||||
|
craftDurationSeconds: 15,
|
||||||
|
enduranceCost: 8,
|
||||||
|
ingredients: [
|
||||||
|
{ materialId: materialMap['Bave de Têtard'], quantity: 3 },
|
||||||
|
{ materialId: materialMap['Écailles de Grenouille'], quantity: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Craft Gilet de Cuir',
|
||||||
|
resultItemName: 'Gilet de Cuir',
|
||||||
|
craftDurationSeconds: 30,
|
||||||
|
enduranceCost: 10,
|
||||||
|
ingredients: [
|
||||||
|
{ materialId: materialMap['Écailles de Grenouille'], quantity: 3 },
|
||||||
|
{ materialId: materialMap['Fragment de Boue'], quantity: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Craft Épée Courte',
|
||||||
|
resultItemName: 'Épée Courte',
|
||||||
|
craftDurationSeconds: 60,
|
||||||
|
enduranceCost: 15,
|
||||||
|
ingredients: [
|
||||||
|
{ materialId: materialMap['Venin de Serpent'], quantity: 2 },
|
||||||
|
{ materialId: materialMap['Fragment de Boue'], quantity: 3 },
|
||||||
|
{ materialId: materialMap['Écailles de Grenouille'], quantity: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const data of RECIPES) {
|
||||||
|
const exists = await recipeRepo.findOne({ where: { name: data.name } });
|
||||||
|
if (!exists) {
|
||||||
|
await recipeRepo.save(recipeRepo.create({
|
||||||
|
name: data.name,
|
||||||
|
resultItemId: itemMap[data.resultItemName],
|
||||||
|
craftDurationSeconds: data.craftDurationSeconds,
|
||||||
|
enduranceCost: data.enduranceCost,
|
||||||
|
ingredients: data.ingredients,
|
||||||
|
}));
|
||||||
|
console.log(`✅ Recette "${data.name}" seedée`);
|
||||||
|
} else {
|
||||||
|
console.log(`⏭ Recette "${data.name}" déjà présente`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mise à jour monsters avec leur drop_material_id
|
||||||
|
for (const [monsterName, materialName] of Object.entries(MONSTER_LOOT)) {
|
||||||
|
const monster = await monsterRepo.findOne({ where: { name: monsterName } });
|
||||||
|
const materialId = materialMap[materialName];
|
||||||
|
if (monster && materialId && monster.dropMaterialId !== materialId) {
|
||||||
|
monster.dropMaterialId = materialId;
|
||||||
|
await monsterRepo.save(monster);
|
||||||
|
console.log(`✅ Monstre "${monsterName}" → drop "${materialName}" mis à jour`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Seed Sprint 3 terminé');
|
||||||
|
await dataSource.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch((err) => {
|
||||||
|
console.error('Seed Sprint 3 échoué :', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
16
src/forge/forge.controller.ts
Normal file
16
src/forge/forge.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Post, Param, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ForgeService } from './forge.service';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Controller('forge')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class ForgeController {
|
||||||
|
constructor(private readonly forgeService: ForgeService) {}
|
||||||
|
|
||||||
|
@Post('upgrade/:charItemId')
|
||||||
|
upgrade(@Param('charItemId') charItemId: string, @Req() req: Request & { user: User }) {
|
||||||
|
return this.forgeService.upgradeItem(charItemId, req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/forge/forge.module.ts
Normal file
14
src/forge/forge.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ForgeService } from './forge.service';
|
||||||
|
import { ForgeController } from './forge.controller';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ItemModule } from '../item/item.module';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Character]), AuthModule, ItemModule],
|
||||||
|
controllers: [ForgeController],
|
||||||
|
providers: [ForgeService],
|
||||||
|
})
|
||||||
|
export class ForgeModule {}
|
||||||
68
src/forge/forge.service.ts
Normal file
68
src/forge/forge.service.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
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é
|
||||||
|
|
||||||
|
// Risque d'échec par niveau cible (GDD exact)
|
||||||
|
const FORGE_FAIL_CHANCE: Record<number, number> = {
|
||||||
|
1: 0,
|
||||||
|
2: 0,
|
||||||
|
3: 0.20,
|
||||||
|
4: 0.30,
|
||||||
|
5: 0.40,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ForgeService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CharacterItem)
|
||||||
|
private readonly charItemRepository: Repository<CharacterItem>,
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upgradeItem(charItemId: string, user: User) {
|
||||||
|
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
|
||||||
|
if (!char) throw new BadRequestException('Aucun personnage trouvé');
|
||||||
|
|
||||||
|
const charItem = await this.charItemRepository.findOne({
|
||||||
|
where: { id: charItemId, characterId: char.id },
|
||||||
|
});
|
||||||
|
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
||||||
|
if (charItem.forgeLevel >= MAX_FORGE_LEVEL) {
|
||||||
|
throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLevel = charItem.forgeLevel + 1;
|
||||||
|
const failChance = FORGE_FAIL_CHANCE[targetLevel] ?? 0;
|
||||||
|
const success = Math.random() >= failChance;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
charItem.forgeLevel = targetLevel;
|
||||||
|
await this.charItemRepository.save(charItem);
|
||||||
|
|
||||||
|
const statLabel = charItem.item.type === 'weapon'
|
||||||
|
? `+${FORGE_BONUS_PER_LEVEL} ATK`
|
||||||
|
: `+${FORGE_BONUS_PER_LEVEL} DEF`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
forgeLevel: charItem.forgeLevel,
|
||||||
|
item: charItem.item.name,
|
||||||
|
message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
forgeLevel: charItem.forgeLevel,
|
||||||
|
item: charItem.item.name,
|
||||||
|
message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}].`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/item/character-item.entity.ts
Normal file
39
src/item/character-item.entity.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { Item } from './item.entity';
|
||||||
|
|
||||||
|
@Entity('character_items')
|
||||||
|
export class CharacterItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id' })
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character)
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character: Character;
|
||||||
|
|
||||||
|
@Column({ name: 'item_id' })
|
||||||
|
itemId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Item, { eager: true })
|
||||||
|
@JoinColumn({ name: 'item_id' })
|
||||||
|
item: Item;
|
||||||
|
|
||||||
|
@Column({ name: 'forge_level', default: 0 })
|
||||||
|
forgeLevel: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
equipped: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'acquired_at' })
|
||||||
|
acquiredAt: Date;
|
||||||
|
}
|
||||||
33
src/item/item.controller.ts
Normal file
33
src/item/item.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ItemService } from './item.service';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Controller('items')
|
||||||
|
export class ItemController {
|
||||||
|
constructor(private readonly itemService: ItemService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.itemService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('inventory')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
getInventory(@Req() req: Request & { user: User }) {
|
||||||
|
return this.itemService.getInventory(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('equip/:charItemId')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
equip(@Param('charItemId') charItemId: string, @Req() req: Request & { user: User }) {
|
||||||
|
return this.itemService.equip(charItemId, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('unequip/:slot')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
unequip(@Param('slot') slot: 'weapon' | 'armor', @Req() req: Request & { user: User }) {
|
||||||
|
return this.itemService.unequip(slot, req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/item/item.entity.ts
Normal file
43
src/item/item.entity.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
|
export type ItemType = 'weapon' | 'armor';
|
||||||
|
export type ItemRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
|
||||||
|
@Entity('items')
|
||||||
|
export class Item {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
type: ItemType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
rarity: ItemRarity;
|
||||||
|
|
||||||
|
@Column({ name: 'attack_bonus', default: 0 })
|
||||||
|
attackBonus: number;
|
||||||
|
|
||||||
|
@Column({ name: 'defense_bonus', default: 0 })
|
||||||
|
defenseBonus: number;
|
||||||
|
|
||||||
|
@Column({ name: 'force_bonus', default: 0 })
|
||||||
|
forceBonus: number;
|
||||||
|
|
||||||
|
@Column({ name: 'agilite_bonus', default: 0 })
|
||||||
|
agiliteBonus: number;
|
||||||
|
|
||||||
|
@Column({ name: 'intelligence_bonus', default: 0 })
|
||||||
|
intelligenceBonus: number;
|
||||||
|
|
||||||
|
@Column({ name: 'chance_bonus', default: 0 })
|
||||||
|
chanceBonus: number;
|
||||||
|
|
||||||
|
@Column({ name: 'vitalite_bonus', default: 0 })
|
||||||
|
vitaliteBonus: number;
|
||||||
|
}
|
||||||
16
src/item/item.module.ts
Normal file
16
src/item/item.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Item } from './item.entity';
|
||||||
|
import { CharacterItem } from './character-item.entity';
|
||||||
|
import { ItemService } from './item.service';
|
||||||
|
import { ItemController } from './item.controller';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Item, CharacterItem, Character]), AuthModule],
|
||||||
|
controllers: [ItemController],
|
||||||
|
providers: [ItemService],
|
||||||
|
exports: [ItemService, TypeOrmModule],
|
||||||
|
})
|
||||||
|
export class ItemModule {}
|
||||||
100
src/item/item.service.ts
Normal file
100
src/item/item.service.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Item } from './item.entity';
|
||||||
|
import { CharacterItem } from './character-item.entity';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ItemService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Item)
|
||||||
|
private readonly itemRepository: Repository<Item>,
|
||||||
|
@InjectRepository(CharacterItem)
|
||||||
|
private readonly charItemRepository: Repository<CharacterItem>,
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findAll() {
|
||||||
|
return this.itemRepository.find({ order: { rarity: 'ASC', name: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInventory(user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
return this.charItemRepository.find({
|
||||||
|
where: { characterId: char.id },
|
||||||
|
order: { acquiredAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async equip(charItemId: string, user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
const charItem = await this.charItemRepository.findOne({
|
||||||
|
where: { id: charItemId, characterId: char.id },
|
||||||
|
});
|
||||||
|
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
|
||||||
|
|
||||||
|
// Déséquiper l'item du même slot (type) si existe
|
||||||
|
const currentEquipped = await this.charItemRepository
|
||||||
|
.createQueryBuilder('ci')
|
||||||
|
.leftJoinAndSelect('ci.item', 'item')
|
||||||
|
.where('ci.characterId = :cid', { cid: char.id })
|
||||||
|
.andWhere('ci.equipped = true')
|
||||||
|
.andWhere('item.type = :type', { type: charItem.item.type })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (currentEquipped) {
|
||||||
|
currentEquipped.equipped = false;
|
||||||
|
await this.charItemRepository.save(currentEquipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
charItem.equipped = true;
|
||||||
|
await this.charItemRepository.save(charItem);
|
||||||
|
return { equipped: true, item: charItem };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unequip(slot: 'weapon' | 'armor', user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
const charItem = await this.charItemRepository
|
||||||
|
.createQueryBuilder('ci')
|
||||||
|
.leftJoinAndSelect('ci.item', 'item')
|
||||||
|
.where('ci.characterId = :cid', { cid: char.id })
|
||||||
|
.andWhere('ci.equipped = true')
|
||||||
|
.andWhere('item.type = :type', { type: slot })
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!charItem) throw new NotFoundException(`Aucun ${slot} équipé`);
|
||||||
|
charItem.equipped = false;
|
||||||
|
await this.charItemRepository.save(charItem);
|
||||||
|
return { unequipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisé par CombatService pour charger l'équipement actif
|
||||||
|
async getEquippedItems(characterId: string): Promise<{ weapon: CharacterItem | null; armor: CharacterItem | null }> {
|
||||||
|
const equipped = await this.charItemRepository
|
||||||
|
.createQueryBuilder('ci')
|
||||||
|
.leftJoinAndSelect('ci.item', 'item')
|
||||||
|
.where('ci.characterId = :cid', { cid: characterId })
|
||||||
|
.andWhere('ci.equipped = true')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return {
|
||||||
|
weapon: equipped.find(ci => ci.item.type === 'weapon') ?? null,
|
||||||
|
armor: equipped.find(ci => ci.item.type === 'armor') ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisé par CraftService pour ajouter un item crafté
|
||||||
|
async addItemToInventory(characterId: string, itemId: string): Promise<CharacterItem> {
|
||||||
|
const charItem = this.charItemRepository.create({ characterId, itemId, forgeLevel: 0, equipped: false });
|
||||||
|
return this.charItemRepository.save(charItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCharacter(user: User): Promise<Character> {
|
||||||
|
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
|
||||||
|
if (!char) throw new BadRequestException('Aucun personnage trouvé');
|
||||||
|
return char;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/material/character-material.entity.ts
Normal file
32
src/material/character-material.entity.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { Material } from './material.entity';
|
||||||
|
|
||||||
|
@Entity('character_materials')
|
||||||
|
export class CharacterMaterial {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'character_id' })
|
||||||
|
characterId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Character)
|
||||||
|
@JoinColumn({ name: 'character_id' })
|
||||||
|
character: Character;
|
||||||
|
|
||||||
|
@Column({ name: 'material_id' })
|
||||||
|
materialId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Material, { eager: true })
|
||||||
|
@JoinColumn({ name: 'material_id' })
|
||||||
|
material: Material;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
21
src/material/material.controller.ts
Normal file
21
src/material/material.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { MaterialService } from './material.service';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Controller('materials')
|
||||||
|
export class MaterialController {
|
||||||
|
constructor(private readonly materialService: MaterialService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.materialService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('inventory')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
getInventory(@Req() req: Request & { user: User }) {
|
||||||
|
return this.materialService.getInventory(req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/material/material.entity.ts
Normal file
18
src/material/material.entity.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
|
export type MaterialRarity = 'common' | 'rare' | 'epic';
|
||||||
|
|
||||||
|
@Entity('materials')
|
||||||
|
export class Material {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
rarity: MaterialRarity;
|
||||||
|
}
|
||||||
16
src/material/material.module.ts
Normal file
16
src/material/material.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Material } from './material.entity';
|
||||||
|
import { CharacterMaterial } from './character-material.entity';
|
||||||
|
import { MaterialService } from './material.service';
|
||||||
|
import { MaterialController } from './material.controller';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Material, CharacterMaterial, Character]), AuthModule],
|
||||||
|
controllers: [MaterialController],
|
||||||
|
providers: [MaterialService],
|
||||||
|
exports: [MaterialService, TypeOrmModule],
|
||||||
|
})
|
||||||
|
export class MaterialModule {}
|
||||||
61
src/material/material.service.ts
Normal file
61
src/material/material.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { MoreThan, Repository } from 'typeorm';
|
||||||
|
import { Material } from './material.entity';
|
||||||
|
import { CharacterMaterial } from './character-material.entity';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MaterialService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Material)
|
||||||
|
private readonly materialRepository: Repository<Material>,
|
||||||
|
@InjectRepository(CharacterMaterial)
|
||||||
|
private readonly charMatRepository: Repository<CharacterMaterial>,
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findAll() {
|
||||||
|
return this.materialRepository.find({ order: { rarity: 'ASC', name: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInventory(user: User) {
|
||||||
|
const char = await this.getCharacter(user);
|
||||||
|
return this.charMatRepository.find({
|
||||||
|
where: { characterId: char.id, quantity: MoreThan(0) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appelé par CombatService après victoire (loot)
|
||||||
|
async addMaterial(characterId: string, materialId: string, quantity: number): Promise<CharacterMaterial> {
|
||||||
|
let entry = await this.charMatRepository.findOne({ where: { characterId, materialId } });
|
||||||
|
if (entry) {
|
||||||
|
entry.quantity += quantity;
|
||||||
|
} else {
|
||||||
|
entry = this.charMatRepository.create({ characterId, materialId, quantity });
|
||||||
|
}
|
||||||
|
return this.charMatRepository.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appelé par CraftService pour consommer les ingrédients
|
||||||
|
async consumeMaterials(characterId: string, ingredients: { materialId: string; quantity: number }[]): Promise<void> {
|
||||||
|
for (const ing of ingredients) {
|
||||||
|
const entry = await this.charMatRepository.findOne({
|
||||||
|
where: { characterId, materialId: ing.materialId },
|
||||||
|
});
|
||||||
|
if (!entry || entry.quantity < ing.quantity) {
|
||||||
|
throw new BadRequestException('Matériaux insuffisants pour ce craft');
|
||||||
|
}
|
||||||
|
entry.quantity -= ing.quantity;
|
||||||
|
await this.charMatRepository.save(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCharacter(user: User): Promise<Character> {
|
||||||
|
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
|
||||||
|
if (!char) throw new BadRequestException('Aucun personnage trouvé');
|
||||||
|
return char;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,4 +36,7 @@ export class Monster {
|
|||||||
|
|
||||||
@Column({ name: 'gold_max' })
|
@Column({ name: 'gold_max' })
|
||||||
goldMax: number;
|
goldMax: number;
|
||||||
|
|
||||||
|
@Column({ name: 'drop_material_id', type: 'varchar', nullable: true })
|
||||||
|
dropMaterialId: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user