feat(sprint3): items + forge + craft + loot — équipement, artisanat lazy-calc, forge risque GDD

This commit is contained in:
2026-03-15 08:22:20 +01:00
parent 6d1230d16a
commit 23f7dd0f3c
25 changed files with 1169 additions and 2 deletions

View 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;
}

View 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
View 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
View 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;
}
}

View 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[];
}