feat(sprint3): items + forge + craft + loot — équipement, artisanat lazy-calc, forge risque GDD
This commit is contained in:
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[];
|
||||
}
|
||||
Reference in New Issue
Block a user