feat: craft/drops — 10 matériaux, 12 recettes, drop rate variable
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s

- 10 matériaux Égouts/Désert (Poil de Rat → Œil du Sphinx)
- 12 items craftables dont 1 legendary (Sceptre Prophétique)
- 12 recettes cross-zone avec ingrédients cohérents
- 15 monstres mappés à leur drop (tous les Égouts/Désert)
- Drop rate variable par difficulté relative (25-80%)
- Quantité drop variable (1-3 selon boss/difficulté)
This commit is contained in:
2026-03-24 20:07:18 +01:00
parent 6938eedcda
commit 47c90e4d55
2 changed files with 376 additions and 6 deletions

View File

@@ -19,6 +19,36 @@ import {
} from './combat.engine';
const COMBAT_ENDURANCE_COST = 5;
/**
* Drop rate variable basé sur la difficulté relative monstre vs joueur.
* Monstre facile = moins de drop, monstre difficile = plus de drop + quantité.
* Boss de zone (maxLevel ≥ 9 et spread ≥ 3) = 80% + 2-3 drops.
*/
function computeDropRate(
playerLevel: number,
monsterMinLevel: number,
monsterMaxLevel: number,
): { dropRate: number; dropQty: number } {
const monsterAvgLevel = (monsterMinLevel + monsterMaxLevel) / 2;
const diff = monsterAvgLevel - playerLevel;
const isBoss = (monsterMaxLevel - monsterMinLevel) >= 3 && monsterMaxLevel >= 9;
if (isBoss) {
return { dropRate: 0.80, dropQty: 2 + (Math.random() < 0.5 ? 1 : 0) }; // 2-3
}
if (diff >= 2) {
return { dropRate: 0.60, dropQty: 1 + (Math.random() < 0.3 ? 1 : 0) }; // 1-2
}
if (diff >= 0) {
return { dropRate: 0.50, dropQty: 1 + (Math.random() < 0.2 ? 1 : 0) }; // 1-2
}
if (diff >= -2) {
return { dropRate: 0.40, dropQty: 1 };
}
// Très facile (level >> monstre)
return { dropRate: 0.25, dropQty: 1 };
}
const DEFEAT_ENDURANCE_PENALTY = 25;
const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite
const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire
@@ -166,13 +196,16 @@ export class CombatService {
}
}
// Loot matériaux — 40% de chance après victoire
// Loot matériaux — drop rate variable par difficulté relative
let lootMaterial: { name: string; quantity: number } | null = null;
let lootedMaterialId: string | 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 };
lootedMaterialId = monster.dropMaterialId;
if (result.winner === 'player' && monster.dropMaterialId) {
const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel);
if (Math.random() < dropRate) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, dropQty);
lootMaterial = { name: 'matériau', quantity: dropQty };
lootedMaterialId = monster.dropMaterialId;
}
}
// Persister le log
@@ -202,7 +235,7 @@ export class CombatService {
}
if (lootMaterial) {
summaryParts.push(`Loot : 1 matériau obtenu !`);
summaryParts.push(`Loot : ${lootMaterial.quantity} matériau${lootMaterial.quantity > 1 ? 'x' : ''} obtenu${lootMaterial.quantity > 1 ? 's' : ''} !`);
}
return {

View File

@@ -0,0 +1,337 @@
import 'reflect-metadata';
import { AppDataSource } from './data-source';
import { Material } from '../material/material.entity';
import { Item } from '../item/item.entity';
import { Recipe } from '../craft/recipe.entity';
import { Monster } from '../monster/monster.entity';
// ──────────────────────────────────────────────
// MATÉRIAUX — 10 nouveaux (Égouts + Désert)
// ──────────────────────────────────────────────
const NEW_MATERIALS: Partial<Material>[] = [
// Égouts
{ name: 'Poil de Rat', description: 'Poil rêche arraché à un rat d\'égout. Sert de rembourrage.', rarity: 'common' },
{ name: 'Gelée Toxique', description: 'Substance corrosive récupérée sur un slime.', rarity: 'common' },
{ name: 'Fil de Soie Géant', description: 'Fil incroyablement résistant tissé par une araignée géante.', rarity: 'rare' },
{ name: 'Cuir de Croco', description: 'Cuir épais et écailleux, presque impénétrable.', rarity: 'rare' },
{ name: 'Couronne du Roi', description: 'Couronne tordue portée par le Roi des Rats. Irradie de pouvoir.', rarity: 'epic' },
// Désert
{ name: 'Dard de Scorpion', description: 'Dard venimeux encore suintant.', rarity: 'common' },
{ name: 'Plume de Vautour', description: 'Plume noire et résistante, légère comme le vent.', rarity: 'common' },
{ name: 'Bandelette Maudite', description: 'Tissu ancien imprégné de magie noire.', rarity: 'rare' },
{ name: 'Sable Cristallisé', description: 'Grain de sable fusionné en cristal par la chaleur du désert.', rarity: 'rare' },
{ name: 'Œil du Sphinx', description: 'Gemme mystique arrachée au Sphinx. Pulse d\'énergie ancienne.', rarity: 'epic' },
];
// ──────────────────────────────────────────────
// ITEMS CRAFTABLES — 12 nouveaux
// ──────────────────────────────────────────────
const NEW_ITEMS: Partial<Item>[] = [
// Marais (2 nouveaux)
{ name: 'Potion Antipoison', type: 'consumable', rarity: 'common', attackBonus: 0, defenseBonus: 0, forceBonus: 20, buyPrice: 0, minLevel: 1, zone: null, description: 'Neutralise les poisons et restaure 20 endurance.' },
{ name: 'Bouclier de Boue', type: 'armor', rarity: 'common', attackBonus: 0, defenseBonus: 4, buyPrice: 0, minLevel: 2, zone: 'marais', description: 'Forgé dans la boue cristallisée des golems.' },
// Égouts (5 nouveaux)
{ name: 'Lame Empoisonnée', type: 'weapon', rarity: 'rare', attackBonus: 11, defenseBonus: 0, buyPrice: 0, minLevel: 5, zone: 'egouts', description: 'Chaque coup inflige un poison insidieux.' },
{ name: 'Armure de Soie', type: 'armor', rarity: 'rare', attackBonus: 0, defenseBonus: 8, agiliteBonus: 2, buyPrice: 0, minLevel: 5, zone: 'egouts', description: 'Légère et résistante, tissée par des araignées géantes.' },
{ name: 'Plastron de Croco', type: 'armor', rarity: 'rare', attackBonus: 0, defenseBonus: 11, buyPrice: 0, minLevel: 7, zone: 'egouts', description: 'Cuir de crocodile assemblé en plastron imposant.' },
{ name: 'Grande Potion de Soin', type: 'consumable', rarity: 'rare', attackBonus: 0, defenseBonus: 0, forceBonus: 0, buyPrice: 0, minLevel: 4, zone: null, description: 'Restaure 80% des PV.' },
{ name: 'Épée du Roi', type: 'weapon', rarity: 'epic', attackBonus: 16, defenseBonus: 0, buyPrice: 0, minLevel: 8, zone: 'egouts', description: 'Forgée avec la couronne du Roi des Rats.' },
// Désert (5 nouveaux)
{ name: 'Dague du Scorpion', type: 'weapon', rarity: 'rare', attackBonus: 14, defenseBonus: 0, buyPrice: 0, minLevel: 9, zone: 'desert', description: 'Le venin du scorpion suinte encore de la lame.' },
{ name: 'Cape du Vautour', type: 'armor', rarity: 'rare', attackBonus: 0, defenseBonus: 12, agiliteBonus: 3, buyPrice: 0, minLevel: 9, zone: 'desert', description: 'Cape de plumes noires, silencieuse et protectrice.' },
{ name: 'Armure de la Momie', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 16, buyPrice: 0, minLevel: 11, zone: 'desert', description: 'Bandelettes enchantées, dures comme le roc.' },
{ name: 'Lame du Désert', type: 'weapon', rarity: 'epic', attackBonus: 22, defenseBonus: 0, buyPrice: 0, minLevel: 11, zone: 'desert', description: 'Cristal de sable fusionné en lame tranchante.' },
{ name: 'Sceptre Prophétique', type: 'weapon', rarity: 'legendary', attackBonus: 30, defenseBonus: 0, intelligenceBonus: 5, buyPrice: 0, minLevel: 13, zone: 'desert', description: 'Seul artefact capable de canaliser la vision du Sphinx.' },
];
// ──────────────────────────────────────────────
// MONSTER → MATERIAL LOOT MAPPING
// ──────────────────────────────────────────────
const MONSTER_LOOT: Record<string, string> = {
// Égouts
'Rat d\'Égout': 'Poil de Rat',
'Slime Toxique': 'Gelée Toxique',
'Araignée Géante': 'Fil de Soie Géant',
'Crocodile': 'Cuir de Croco',
'Roi des Rats': 'Couronne du Roi',
// Désert
'Scorpion': 'Dard de Scorpion',
'Vautour': 'Plume de Vautour',
'Momie': 'Bandelette Maudite',
'Ver des Sables': 'Sable Cristallisé',
'Sphinx': 'Œil du Sphinx',
};
// ──────────────────────────────────────────────
// RECETTES — 12 nouvelles (nom ingrédient → résolu en runtime)
// ──────────────────────────────────────────────
interface RecipeDef {
name: string;
resultItemName: string;
craftDurationSeconds: number;
enduranceCost: number;
ingredientNames: { materialName: string; quantity: number }[];
}
const NEW_RECIPES: RecipeDef[] = [
// Marais (2)
{
name: 'Craft Potion Antipoison',
resultItemName: 'Potion Antipoison',
craftDurationSeconds: 10,
enduranceCost: 5,
ingredientNames: [
{ materialName: 'Spores Vénéneuses', quantity: 2 },
{ materialName: 'Venin de Serpent', quantity: 1 },
],
},
{
name: 'Craft Bouclier de Boue',
resultItemName: 'Bouclier de Boue',
craftDurationSeconds: 25,
enduranceCost: 10,
ingredientNames: [
{ materialName: 'Fragment de Boue', quantity: 4 },
{ materialName: 'Écailles de Grenouille', quantity: 2 },
],
},
// Égouts (5)
{
name: 'Craft Lame Empoisonnée',
resultItemName: 'Lame Empoisonnée',
craftDurationSeconds: 45,
enduranceCost: 12,
ingredientNames: [
{ materialName: 'Gelée Toxique', quantity: 2 },
{ materialName: 'Venin de Serpent', quantity: 1 },
{ materialName: 'Poil de Rat', quantity: 3 },
],
},
{
name: 'Craft Armure de Soie',
resultItemName: 'Armure de Soie',
craftDurationSeconds: 60,
enduranceCost: 14,
ingredientNames: [
{ materialName: 'Fil de Soie Géant', quantity: 4 },
{ materialName: 'Poil de Rat', quantity: 2 },
],
},
{
name: 'Craft Plastron de Croco',
resultItemName: 'Plastron de Croco',
craftDurationSeconds: 90,
enduranceCost: 16,
ingredientNames: [
{ materialName: 'Cuir de Croco', quantity: 3 },
{ materialName: 'Fragment de Boue', quantity: 2 },
{ materialName: 'Fil de Soie Géant', quantity: 1 },
],
},
{
name: 'Craft Grande Potion de Soin',
resultItemName: 'Grande Potion de Soin',
craftDurationSeconds: 20,
enduranceCost: 8,
ingredientNames: [
{ materialName: 'Gelée Toxique', quantity: 2 },
{ materialName: 'Spores Vénéneuses', quantity: 2 },
],
},
{
name: 'Craft Épée du Roi',
resultItemName: 'Épée du Roi',
craftDurationSeconds: 120,
enduranceCost: 20,
ingredientNames: [
{ materialName: 'Couronne du Roi', quantity: 1 },
{ materialName: 'Cuir de Croco', quantity: 3 },
{ materialName: 'Gelée Toxique', quantity: 2 },
],
},
// Désert (5)
{
name: 'Craft Dague du Scorpion',
resultItemName: 'Dague du Scorpion',
craftDurationSeconds: 60,
enduranceCost: 14,
ingredientNames: [
{ materialName: 'Dard de Scorpion', quantity: 3 },
{ materialName: 'Plume de Vautour', quantity: 2 },
],
},
{
name: 'Craft Cape du Vautour',
resultItemName: 'Cape du Vautour',
craftDurationSeconds: 90,
enduranceCost: 16,
ingredientNames: [
{ materialName: 'Plume de Vautour', quantity: 4 },
{ materialName: 'Bandelette Maudite', quantity: 2 },
],
},
{
name: 'Craft Armure de la Momie',
resultItemName: 'Armure de la Momie',
craftDurationSeconds: 150,
enduranceCost: 20,
ingredientNames: [
{ materialName: 'Bandelette Maudite', quantity: 3 },
{ materialName: 'Sable Cristallisé', quantity: 2 },
{ materialName: 'Cuir de Croco', quantity: 1 },
],
},
{
name: 'Craft Lame du Désert',
resultItemName: 'Lame du Désert',
craftDurationSeconds: 180,
enduranceCost: 22,
ingredientNames: [
{ materialName: 'Sable Cristallisé', quantity: 3 },
{ materialName: 'Dard de Scorpion', quantity: 2 },
{ materialName: 'Bandelette Maudite', quantity: 1 },
],
},
{
name: 'Craft Sceptre Prophétique',
resultItemName: 'Sceptre Prophétique',
craftDurationSeconds: 300,
enduranceCost: 25,
ingredientNames: [
{ materialName: 'Œil du Sphinx', quantity: 1 },
{ materialName: 'Sable Cristallisé', quantity: 3 },
{ materialName: 'Bandelette Maudite', quantity: 2 },
],
},
];
// ──────────────────────────────────────────────
// SEED RUNNER
// ──────────────────────────────────────────────
async function seed() {
await AppDataSource.initialize();
console.log('DB connectée (MySQL)');
const materialRepo = AppDataSource.getRepository(Material);
const itemRepo = AppDataSource.getRepository(Item);
const recipeRepo = AppDataSource.getRepository(Recipe);
const monsterRepo = AppDataSource.getRepository(Monster);
// 1. Seed matériaux
console.log('\n── Matériaux ──');
const materialMap: Record<string, string> = {};
// Charger les matériaux existants dans le map
const existingMats = await materialRepo.find();
for (const mat of existingMats) {
materialMap[mat.name] = mat.id;
}
for (const data of NEW_MATERIALS) {
if (materialMap[data.name!]) {
console.log(`${data.name} (déjà présent)`);
continue;
}
const mat = await materialRepo.save(materialRepo.create(data));
materialMap[mat.name] = mat.id;
console.log(`${data.name} (${data.rarity})`);
}
// 2. Seed items craftables
console.log('\n── Items craftables ──');
const itemMap: Record<string, string> = {};
const existingItems = await itemRepo.find();
for (const item of existingItems) {
itemMap[item.name] = item.id;
}
for (const data of NEW_ITEMS) {
if (itemMap[data.name!]) {
console.log(`${data.name} (déjà présent)`);
continue;
}
const item = await itemRepo.save(itemRepo.create(data));
itemMap[item.name] = item.id;
console.log(`${data.name} (${data.rarity}, ${data.type})`);
}
// 3. Mapper les drops des monstres Égouts/Désert
console.log('\n── Monster drops ──');
for (const [monsterName, materialName] of Object.entries(MONSTER_LOOT)) {
const materialId = materialMap[materialName];
if (!materialId) {
console.error(`❌ Matériau "${materialName}" introuvable pour ${monsterName}`);
continue;
}
const monster = await monsterRepo.findOne({ where: { name: monsterName } });
if (!monster) {
console.error(`❌ Monstre "${monsterName}" introuvable`);
continue;
}
if (monster.dropMaterialId === materialId) {
console.log(`${monsterName}${materialName} (déjà mappé)`);
continue;
}
monster.dropMaterialId = materialId;
await monsterRepo.save(monster);
console.log(`${monsterName}${materialName}`);
}
// 4. Seed recettes
console.log('\n── Recettes ──');
for (const recipeDef of NEW_RECIPES) {
const exists = await recipeRepo.findOne({ where: { name: recipeDef.name } });
if (exists) {
console.log(`${recipeDef.name} (déjà présente)`);
continue;
}
const resultItemId = itemMap[recipeDef.resultItemName];
if (!resultItemId) {
console.error(`❌ Item "${recipeDef.resultItemName}" introuvable pour recette ${recipeDef.name}`);
continue;
}
const ingredients = recipeDef.ingredientNames.map((ing) => {
const matId = materialMap[ing.materialName];
if (!matId) {
throw new Error(`Matériau "${ing.materialName}" introuvable pour recette ${recipeDef.name}`);
}
return { materialId: matId, quantity: ing.quantity };
});
await recipeRepo.save(
recipeRepo.create({
name: recipeDef.name,
resultItemId,
craftDurationSeconds: recipeDef.craftDurationSeconds,
enduranceCost: recipeDef.enduranceCost,
ingredients,
}),
);
console.log(`${recipeDef.name} (${recipeDef.ingredientNames.length} ingrédients, ${recipeDef.craftDurationSeconds}s)`);
}
// Résumé
const totalMats = await materialRepo.count();
const totalItems = await itemRepo.count();
const totalRecipes = await recipeRepo.count();
const monstersWithDrop = await monsterRepo.count({ where: { dropMaterialId: undefined } as any });
const totalMonsters = await monsterRepo.count();
console.log(`\n✅ Seed craft/drops terminé`);
console.log(` Matériaux: ${totalMats} | Items: ${totalItems} | Recettes: ${totalRecipes} | Monstres: ${totalMonsters}`);
await AppDataSource.destroy();
}
seed().catch((err) => {
console.error('Seed craft/drops échoué :', err);
process.exit(1);
});