Skip to main content

proof_engine/crafting/
recipes.rs

1// crafting/recipes.rs — Recipe and crafting formula system
2
3use std::collections::HashMap;
4
5// ---------------------------------------------------------------------------
6// Quality tier
7// ---------------------------------------------------------------------------
8
9/// Tiered quality levels for crafted items, with stat multipliers.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub enum QualityTier {
12    Poor,
13    Common,
14    Fine,
15    Exceptional,
16    Masterwork,
17    Legendary,
18}
19
20impl QualityTier {
21    /// Returns the stat multiplier for this quality tier.
22    pub fn stat_multiplier(&self) -> f32 {
23        match self {
24            QualityTier::Poor        => 0.60,
25            QualityTier::Common      => 1.00,
26            QualityTier::Fine        => 1.20,
27            QualityTier::Exceptional => 1.50,
28            QualityTier::Masterwork  => 2.00,
29            QualityTier::Legendary   => 3.00,
30        }
31    }
32
33    /// Convert a raw quality byte (0–255) to a tier.
34    pub fn from_value(v: u8) -> Self {
35        match v {
36            0..=39   => QualityTier::Poor,
37            40..=99  => QualityTier::Common,
38            100..=149 => QualityTier::Fine,
39            150..=199 => QualityTier::Exceptional,
40            200..=239 => QualityTier::Masterwork,
41            240..=255 => QualityTier::Legendary,
42        }
43    }
44
45    /// The minimum raw quality value that maps to this tier.
46    pub fn threshold(&self) -> u8 {
47        match self {
48            QualityTier::Poor        => 0,
49            QualityTier::Common      => 40,
50            QualityTier::Fine        => 100,
51            QualityTier::Exceptional => 150,
52            QualityTier::Masterwork  => 200,
53            QualityTier::Legendary   => 240,
54        }
55    }
56
57    /// Human-readable label.
58    pub fn label(&self) -> &'static str {
59        match self {
60            QualityTier::Poor        => "Poor",
61            QualityTier::Common      => "Common",
62            QualityTier::Fine        => "Fine",
63            QualityTier::Exceptional => "Exceptional",
64            QualityTier::Masterwork  => "Masterwork",
65            QualityTier::Legendary   => "Legendary",
66        }
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Recipe category
72// ---------------------------------------------------------------------------
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub enum RecipeCategory {
76    Smithing,
77    Alchemy,
78    Cooking,
79    Enchanting,
80    Engineering,
81    Tailoring,
82    Woodworking,
83    Jeweling,
84}
85
86impl RecipeCategory {
87    pub fn label(&self) -> &'static str {
88        match self {
89            RecipeCategory::Smithing     => "Smithing",
90            RecipeCategory::Alchemy      => "Alchemy",
91            RecipeCategory::Cooking      => "Cooking",
92            RecipeCategory::Enchanting   => "Enchanting",
93            RecipeCategory::Engineering  => "Engineering",
94            RecipeCategory::Tailoring    => "Tailoring",
95            RecipeCategory::Woodworking  => "Woodworking",
96            RecipeCategory::Jeweling     => "Jeweling",
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Ingredient and CraftResult
103// ---------------------------------------------------------------------------
104
105/// A single ingredient required for a recipe.
106#[derive(Debug, Clone)]
107pub struct Ingredient {
108    pub item_id: String,
109    pub quantity: u32,
110    /// Minimum quality byte (0–255) the ingredient must have.
111    pub quality_min: u8,
112}
113
114impl Ingredient {
115    pub fn new(item_id: impl Into<String>, quantity: u32, quality_min: u8) -> Self {
116        Self { item_id: item_id.into(), quantity, quality_min }
117    }
118
119    /// Simple helper — quality_min = 0.
120    pub fn basic(item_id: impl Into<String>, quantity: u32) -> Self {
121        Self::new(item_id, quantity, 0)
122    }
123}
124
125/// One possible output from a crafting operation.
126#[derive(Debug, Clone)]
127pub struct CraftResult {
128    pub item_id: String,
129    pub quantity: u32,
130    /// Base quality byte before skill adjustments.
131    pub quality: u8,
132    /// Probability (0.0–1.0) that this result is produced.
133    pub chance: f32,
134}
135
136impl CraftResult {
137    pub fn new(item_id: impl Into<String>, quantity: u32, quality: u8, chance: f32) -> Self {
138        Self { item_id: item_id.into(), quantity, quality, chance: chance.clamp(0.0, 1.0) }
139    }
140
141    /// Guaranteed (chance = 1.0) result.
142    pub fn guaranteed(item_id: impl Into<String>, quantity: u32, quality: u8) -> Self {
143        Self::new(item_id, quantity, quality, 1.0)
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Recipe
149// ---------------------------------------------------------------------------
150
151/// A complete crafting recipe.
152#[derive(Debug, Clone)]
153pub struct Recipe {
154    pub id: String,
155    pub name: String,
156    pub category: RecipeCategory,
157    pub ingredients: Vec<Ingredient>,
158    pub results: Vec<CraftResult>,
159    /// Minimum crafting skill level required.
160    pub required_level: u32,
161    /// Tool item IDs required to be at the workbench.
162    pub required_tools: Vec<String>,
163    /// Base crafting time in seconds.
164    pub duration_secs: f32,
165    /// Experience awarded on completion.
166    pub experience_reward: u32,
167    /// Probability (0.0–1.0) that the player learns this recipe upon discovery attempt.
168    pub discovery_chance: f32,
169}
170
171impl Recipe {
172    pub fn new(
173        id: impl Into<String>,
174        name: impl Into<String>,
175        category: RecipeCategory,
176    ) -> Self {
177        Self {
178            id: id.into(),
179            name: name.into(),
180            category,
181            ingredients: Vec::new(),
182            results: Vec::new(),
183            required_level: 1,
184            required_tools: Vec::new(),
185            duration_secs: 5.0,
186            experience_reward: 10,
187            discovery_chance: 0.0,
188        }
189    }
190
191    pub fn with_ingredient(mut self, ing: Ingredient) -> Self {
192        self.ingredients.push(ing);
193        self
194    }
195
196    pub fn with_result(mut self, res: CraftResult) -> Self {
197        self.results.push(res);
198        self
199    }
200
201    pub fn with_level(mut self, level: u32) -> Self {
202        self.required_level = level;
203        self
204    }
205
206    pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
207        self.required_tools.push(tool.into());
208        self
209    }
210
211    pub fn with_duration(mut self, secs: f32) -> Self {
212        self.duration_secs = secs;
213        self
214    }
215
216    pub fn with_experience(mut self, xp: u32) -> Self {
217        self.experience_reward = xp;
218        self
219    }
220
221    pub fn with_discovery_chance(mut self, chance: f32) -> Self {
222        self.discovery_chance = chance.clamp(0.0, 1.0);
223        self
224    }
225}
226
227// ---------------------------------------------------------------------------
228// RecipeBook
229// ---------------------------------------------------------------------------
230
231/// Central registry of all known recipes.
232#[derive(Debug, Clone)]
233pub struct RecipeBook {
234    recipes: HashMap<String, Recipe>,
235    by_category: HashMap<String, Vec<String>>,
236}
237
238impl RecipeBook {
239    pub fn new() -> Self {
240        Self {
241            recipes: HashMap::new(),
242            by_category: HashMap::new(),
243        }
244    }
245
246    /// Register a recipe in the book.
247    pub fn register(&mut self, recipe: Recipe) {
248        let cat_key = recipe.category.label().to_string();
249        self.by_category
250            .entry(cat_key)
251            .or_insert_with(Vec::new)
252            .push(recipe.id.clone());
253        self.recipes.insert(recipe.id.clone(), recipe);
254    }
255
256    /// Look up a recipe by id.
257    pub fn get(&self, id: &str) -> Option<&Recipe> {
258        self.recipes.get(id)
259    }
260
261    /// All recipes in a given category.
262    pub fn by_category(&self, category: &RecipeCategory) -> Vec<&Recipe> {
263        let key = category.label();
264        match self.by_category.get(key) {
265            None => Vec::new(),
266            Some(ids) => ids
267                .iter()
268                .filter_map(|id| self.recipes.get(id))
269                .collect(),
270        }
271    }
272
273    /// Recipes available to a player of `skill_level`.
274    pub fn available_for_level(&self, skill_level: u32) -> Vec<&Recipe> {
275        self.recipes
276            .values()
277            .filter(|r| r.required_level <= skill_level)
278            .collect()
279    }
280
281    /// Recipes available to a player of `skill_level` in a given category.
282    pub fn available_for_level_and_category(
283        &self,
284        skill_level: u32,
285        category: &RecipeCategory,
286    ) -> Vec<&Recipe> {
287        self.by_category(category)
288            .into_iter()
289            .filter(|r| r.required_level <= skill_level)
290            .collect()
291    }
292
293    /// Total number of registered recipes.
294    pub fn count(&self) -> usize {
295        self.recipes.len()
296    }
297
298    /// All recipe ids.
299    pub fn ids(&self) -> Vec<&str> {
300        self.recipes.keys().map(|s| s.as_str()).collect()
301    }
302
303    /// Build a RecipeBook pre-populated with ~30 default recipes across all categories.
304    pub fn default_recipes() -> Self {
305        let mut book = Self::new();
306
307        // --- SMITHING ---
308        book.register(
309            Recipe::new("iron_sword", "Iron Sword", RecipeCategory::Smithing)
310                .with_ingredient(Ingredient::basic("iron_ingot", 3))
311                .with_ingredient(Ingredient::basic("leather_strip", 1))
312                .with_result(CraftResult::guaranteed("iron_sword", 1, 80))
313                .with_level(5)
314                .with_tool("forge_hammer")
315                .with_duration(12.0)
316                .with_experience(25),
317        );
318        book.register(
319            Recipe::new("iron_shield", "Iron Shield", RecipeCategory::Smithing)
320                .with_ingredient(Ingredient::basic("iron_ingot", 4))
321                .with_ingredient(Ingredient::basic("wooden_plank", 2))
322                .with_result(CraftResult::guaranteed("iron_shield", 1, 75))
323                .with_level(8)
324                .with_tool("forge_hammer")
325                .with_duration(18.0)
326                .with_experience(30),
327        );
328        book.register(
329            Recipe::new("steel_ingot", "Steel Ingot", RecipeCategory::Smithing)
330                .with_ingredient(Ingredient::basic("iron_ingot", 2))
331                .with_ingredient(Ingredient::basic("coal", 3))
332                .with_result(CraftResult::guaranteed("steel_ingot", 1, 90))
333                .with_level(15)
334                .with_duration(8.0)
335                .with_experience(15),
336        );
337        book.register(
338            Recipe::new("steel_sword", "Steel Sword", RecipeCategory::Smithing)
339                .with_ingredient(Ingredient::new("steel_ingot", 3, 80))
340                .with_ingredient(Ingredient::basic("leather_strip", 1))
341                .with_result(CraftResult::guaranteed("steel_sword", 1, 110))
342                .with_level(20)
343                .with_tool("forge_hammer")
344                .with_duration(20.0)
345                .with_experience(50),
346        );
347        book.register(
348            Recipe::new("iron_helmet", "Iron Helmet", RecipeCategory::Smithing)
349                .with_ingredient(Ingredient::basic("iron_ingot", 5))
350                .with_result(CraftResult::guaranteed("iron_helmet", 1, 75))
351                .with_level(10)
352                .with_tool("forge_hammer")
353                .with_duration(15.0)
354                .with_experience(35),
355        );
356
357        // --- ALCHEMY ---
358        book.register(
359            Recipe::new("health_potion_minor", "Minor Health Potion", RecipeCategory::Alchemy)
360                .with_ingredient(Ingredient::basic("red_herb", 2))
361                .with_ingredient(Ingredient::basic("clean_water", 1))
362                .with_result(CraftResult::guaranteed("health_potion_minor", 1, 70))
363                .with_level(1)
364                .with_duration(5.0)
365                .with_experience(8)
366                .with_discovery_chance(0.3),
367        );
368        book.register(
369            Recipe::new("health_potion_major", "Major Health Potion", RecipeCategory::Alchemy)
370                .with_ingredient(Ingredient::new("red_herb", 4, 60))
371                .with_ingredient(Ingredient::basic("clean_water", 1))
372                .with_ingredient(Ingredient::basic("golden_root", 1))
373                .with_result(CraftResult::guaranteed("health_potion_major", 1, 100))
374                .with_level(12)
375                .with_duration(10.0)
376                .with_experience(20),
377        );
378        book.register(
379            Recipe::new("mana_potion", "Mana Potion", RecipeCategory::Alchemy)
380                .with_ingredient(Ingredient::basic("blue_herb", 2))
381                .with_ingredient(Ingredient::basic("spring_water", 1))
382                .with_result(CraftResult::guaranteed("mana_potion", 1, 70))
383                .with_level(5)
384                .with_duration(6.0)
385                .with_experience(12),
386        );
387        book.register(
388            Recipe::new("poison_vial", "Poison Vial", RecipeCategory::Alchemy)
389                .with_ingredient(Ingredient::basic("viper_fang", 1))
390                .with_ingredient(Ingredient::basic("dark_mushroom", 2))
391                .with_result(CraftResult::guaranteed("poison_vial", 2, 80))
392                .with_result(CraftResult::new("potent_poison_vial", 1, 120, 0.15))
393                .with_level(18)
394                .with_duration(14.0)
395                .with_experience(40)
396                .with_discovery_chance(0.1),
397        );
398
399        // --- COOKING ---
400        book.register(
401            Recipe::new("roasted_meat", "Roasted Meat", RecipeCategory::Cooking)
402                .with_ingredient(Ingredient::basic("raw_meat", 2))
403                .with_ingredient(Ingredient::basic("salt", 1))
404                .with_result(CraftResult::guaranteed("roasted_meat", 2, 60))
405                .with_level(1)
406                .with_duration(4.0)
407                .with_experience(5),
408        );
409        book.register(
410            Recipe::new("hearty_stew", "Hearty Stew", RecipeCategory::Cooking)
411                .with_ingredient(Ingredient::basic("raw_meat", 1))
412                .with_ingredient(Ingredient::basic("carrot", 2))
413                .with_ingredient(Ingredient::basic("potato", 2))
414                .with_ingredient(Ingredient::basic("salt", 1))
415                .with_result(CraftResult::guaranteed("hearty_stew", 1, 80))
416                .with_level(8)
417                .with_duration(10.0)
418                .with_experience(18),
419        );
420        book.register(
421            Recipe::new("energy_bread", "Energy Bread", RecipeCategory::Cooking)
422                .with_ingredient(Ingredient::basic("flour", 3))
423                .with_ingredient(Ingredient::basic("honey", 1))
424                .with_result(CraftResult::guaranteed("energy_bread", 2, 65))
425                .with_level(4)
426                .with_duration(6.0)
427                .with_experience(10),
428        );
429
430        // --- ENCHANTING ---
431        book.register(
432            Recipe::new("enchant_sharpness_i", "Sharpness I Enchant", RecipeCategory::Enchanting)
433                .with_ingredient(Ingredient::new("iron_sword", 1, 70))
434                .with_ingredient(Ingredient::basic("magic_dust", 5))
435                .with_result(CraftResult::guaranteed("iron_sword_sharpened", 1, 90))
436                .with_level(10)
437                .with_tool("enchanting_focus")
438                .with_duration(30.0)
439                .with_experience(60),
440        );
441        book.register(
442            Recipe::new("enchant_fire_i", "Fire I Enchant", RecipeCategory::Enchanting)
443                .with_ingredient(Ingredient::new("steel_sword", 1, 90))
444                .with_ingredient(Ingredient::basic("fire_essence", 3))
445                .with_ingredient(Ingredient::basic("magic_dust", 8))
446                .with_result(CraftResult::guaranteed("fire_steel_sword", 1, 120))
447                .with_result(CraftResult::new("ember_steel_sword", 1, 140, 0.10))
448                .with_level(25)
449                .with_tool("enchanting_focus")
450                .with_duration(45.0)
451                .with_experience(100),
452        );
453        book.register(
454            Recipe::new("enchant_ward_armor", "Ward Armor Enchant", RecipeCategory::Enchanting)
455                .with_ingredient(Ingredient::basic("iron_helmet", 1))
456                .with_ingredient(Ingredient::basic("light_rune", 2))
457                .with_ingredient(Ingredient::basic("magic_dust", 6))
458                .with_result(CraftResult::guaranteed("warded_iron_helmet", 1, 95))
459                .with_level(20)
460                .with_tool("enchanting_focus")
461                .with_duration(40.0)
462                .with_experience(80),
463        );
464
465        // --- ENGINEERING ---
466        book.register(
467            Recipe::new("gear_small", "Small Gear", RecipeCategory::Engineering)
468                .with_ingredient(Ingredient::basic("iron_ingot", 1))
469                .with_result(CraftResult::guaranteed("gear_small", 3, 70))
470                .with_level(3)
471                .with_tool("wrench")
472                .with_duration(4.0)
473                .with_experience(8),
474        );
475        book.register(
476            Recipe::new("clockwork_device", "Clockwork Device", RecipeCategory::Engineering)
477                .with_ingredient(Ingredient::basic("gear_small", 6))
478                .with_ingredient(Ingredient::basic("copper_wire", 4))
479                .with_ingredient(Ingredient::basic("spring_coil", 2))
480                .with_result(CraftResult::guaranteed("clockwork_device", 1, 85))
481                .with_level(20)
482                .with_tool("wrench")
483                .with_duration(25.0)
484                .with_experience(55),
485        );
486        book.register(
487            Recipe::new("bomb_smoke", "Smoke Bomb", RecipeCategory::Engineering)
488                .with_ingredient(Ingredient::basic("charcoal_powder", 2))
489                .with_ingredient(Ingredient::basic("sulfur", 1))
490                .with_ingredient(Ingredient::basic("cloth_scrap", 1))
491                .with_result(CraftResult::guaranteed("bomb_smoke", 2, 70))
492                .with_level(15)
493                .with_duration(8.0)
494                .with_experience(22),
495        );
496
497        // --- TAILORING ---
498        book.register(
499            Recipe::new("cloth_tunic", "Cloth Tunic", RecipeCategory::Tailoring)
500                .with_ingredient(Ingredient::basic("cloth_bolt", 3))
501                .with_ingredient(Ingredient::basic("thread_spool", 2))
502                .with_result(CraftResult::guaranteed("cloth_tunic", 1, 60))
503                .with_level(1)
504                .with_tool("sewing_needle")
505                .with_duration(8.0)
506                .with_experience(10),
507        );
508        book.register(
509            Recipe::new("leather_armor", "Leather Armor", RecipeCategory::Tailoring)
510                .with_ingredient(Ingredient::basic("tanned_hide", 4))
511                .with_ingredient(Ingredient::basic("thread_spool", 2))
512                .with_ingredient(Ingredient::basic("iron_buckle", 2))
513                .with_result(CraftResult::guaranteed("leather_armor", 1, 75))
514                .with_level(10)
515                .with_tool("sewing_needle")
516                .with_duration(15.0)
517                .with_experience(30),
518        );
519        book.register(
520            Recipe::new("silk_robe", "Silk Robe", RecipeCategory::Tailoring)
521                .with_ingredient(Ingredient::new("silk_bolt", 5, 80))
522                .with_ingredient(Ingredient::basic("thread_spool", 3))
523                .with_ingredient(Ingredient::basic("gem_dust", 1))
524                .with_result(CraftResult::guaranteed("silk_robe", 1, 100))
525                .with_level(22)
526                .with_tool("sewing_needle")
527                .with_duration(22.0)
528                .with_experience(55),
529        );
530
531        // --- WOODWORKING ---
532        book.register(
533            Recipe::new("wooden_bow", "Wooden Bow", RecipeCategory::Woodworking)
534                .with_ingredient(Ingredient::basic("flexible_branch", 2))
535                .with_ingredient(Ingredient::basic("sinew_string", 1))
536                .with_result(CraftResult::guaranteed("wooden_bow", 1, 65))
537                .with_level(5)
538                .with_tool("carving_knife")
539                .with_duration(10.0)
540                .with_experience(18),
541        );
542        book.register(
543            Recipe::new("wooden_staff", "Wooden Staff", RecipeCategory::Woodworking)
544                .with_ingredient(Ingredient::basic("hardwood_log", 1))
545                .with_result(CraftResult::guaranteed("wooden_staff", 1, 70))
546                .with_level(3)
547                .with_tool("carving_knife")
548                .with_duration(7.0)
549                .with_experience(12),
550        );
551        book.register(
552            Recipe::new("arrow_bundle", "Arrow Bundle", RecipeCategory::Woodworking)
553                .with_ingredient(Ingredient::basic("straight_stick", 10))
554                .with_ingredient(Ingredient::basic("feather", 10))
555                .with_ingredient(Ingredient::basic("iron_tip", 10))
556                .with_result(CraftResult::guaranteed("arrow", 10, 60))
557                .with_level(2)
558                .with_duration(12.0)
559                .with_experience(14),
560        );
561
562        // --- JEWELING ---
563        book.register(
564            Recipe::new("silver_ring", "Silver Ring", RecipeCategory::Jeweling)
565                .with_ingredient(Ingredient::basic("silver_ingot", 1))
566                .with_result(CraftResult::guaranteed("silver_ring", 1, 70))
567                .with_level(5)
568                .with_tool("jeweler_loupe")
569                .with_duration(10.0)
570                .with_experience(20),
571        );
572        book.register(
573            Recipe::new("ruby_necklace", "Ruby Necklace", RecipeCategory::Jeweling)
574                .with_ingredient(Ingredient::basic("gold_chain", 1))
575                .with_ingredient(Ingredient::new("ruby_gem", 1, 100))
576                .with_result(CraftResult::guaranteed("ruby_necklace", 1, 120))
577                .with_result(CraftResult::new("radiant_ruby_necklace", 1, 180, 0.08))
578                .with_level(20)
579                .with_tool("jeweler_loupe")
580                .with_duration(25.0)
581                .with_experience(70),
582        );
583        book.register(
584            Recipe::new("sapphire_amulet", "Sapphire Amulet of Clarity", RecipeCategory::Jeweling)
585                .with_ingredient(Ingredient::basic("mithril_chain", 1))
586                .with_ingredient(Ingredient::new("sapphire_gem", 2, 110))
587                .with_ingredient(Ingredient::basic("magic_dust", 4))
588                .with_result(CraftResult::guaranteed("sapphire_amulet", 1, 140))
589                .with_level(30)
590                .with_tool("jeweler_loupe")
591                .with_duration(35.0)
592                .with_experience(110),
593        );
594
595        book
596    }
597
598    /// Attempt to find a recipe matching these ingredients exactly (for discovery).
599    pub fn find_by_ingredients(&self, ingredient_ids: &[String]) -> Option<&Recipe> {
600        let mut sorted_input = ingredient_ids.to_vec();
601        sorted_input.sort();
602        for recipe in self.recipes.values() {
603            let mut sorted_recipe: Vec<String> =
604                recipe.ingredients.iter().map(|i| i.item_id.clone()).collect();
605            sorted_recipe.sort();
606            if sorted_recipe == sorted_input {
607                return Some(recipe);
608            }
609        }
610        None
611    }
612}
613
614impl Default for RecipeBook {
615    fn default() -> Self {
616        Self::new()
617    }
618}
619
620// ---------------------------------------------------------------------------
621// CraftingCalculator
622// ---------------------------------------------------------------------------
623
624/// Computes actual output values given player stats.
625#[derive(Debug, Clone)]
626pub struct CraftingCalculator;
627
628impl CraftingCalculator {
629    /// Compute the actual output quality (0–255) from base quality, skill, and tool bonus.
630    ///
631    /// Formula: clamp(base + skill_bonus + tool_bonus, 0, 255)
632    /// where skill_bonus = skill_level * 0.4 (capped at +80)
633    pub fn calculate_quality(base: u8, skill_level: u32, tool_bonus: u32) -> u8 {
634        let skill_bonus = ((skill_level as f32 * 0.4) as u32).min(80);
635        let raw = base as u32 + skill_bonus + tool_bonus;
636        raw.min(255) as u8
637    }
638
639    /// Compute the actual output quantity from base quantity, skill level, and luck.
640    ///
641    /// Uses linear interpolation `a + t * (b - a)` where a = base, b = base * 2,
642    /// t is derived from skill_level and luck.
643    pub fn calculate_quantity(base: u32, skill_level: u32, luck: f32) -> u32 {
644        // t ranges from 0 to 1 as skill_level goes from 0 to 100, modified by luck
645        let t = ((skill_level as f32 / 100.0) + luck * 0.2).clamp(0.0, 1.0);
646        let a = base as f32;
647        let b = (base * 2) as f32;
648        // a + t * (b - a) — explicit form, no lerp
649        let result = a + t * (b - a);
650        result.round() as u32
651    }
652
653    /// Compute the actual crafting duration (seconds) from base duration, skill, and tool speed.
654    ///
655    /// Higher skill and tool speed reduce the duration (capped at 10% of base).
656    pub fn calculate_duration(base: f32, skill_level: u32, tool_speed: f32) -> f32 {
657        let skill_reduction = (skill_level as f32 * 0.005).min(0.5);
658        let tool_reduction = tool_speed.clamp(0.0, 0.4);
659        let total_reduction = (skill_reduction + tool_reduction).min(0.9);
660        let t = 1.0 - total_reduction;
661        // a + t * (b - a) where a = base, b = base * 0.1  →  base * t
662        // Simplified: final = base * t, but spelled in lerp form for clarity
663        let a = base;
664        let b = base * 0.1;
665        let result = a + (1.0 - t) * (b - a);  // t here means "how reduced"
666        result.max(0.5)
667    }
668
669    /// Evaluate whether a probabilistic CraftResult fires given skill and rng value (0..1).
670    pub fn evaluate_chance(result: &CraftResult, skill_level: u32, rng: f32) -> bool {
671        let bonus = (skill_level as f32 * 0.001).min(0.1);
672        let effective_chance = (result.chance + bonus).min(1.0);
673        rng <= effective_chance
674    }
675
676    /// Compute XP gained for completing a recipe, boosted by quality.
677    pub fn calculate_experience(base_xp: u32, quality: u8) -> u32 {
678        let quality_factor = 1.0 + (quality as f32 - 80.0).max(0.0) * 0.005;
679        ((base_xp as f32) * quality_factor).round() as u32
680    }
681}
682
683// ---------------------------------------------------------------------------
684// MasterySystem
685// ---------------------------------------------------------------------------
686
687/// Per-category mastery XP and level tracking.
688#[derive(Debug, Clone)]
689pub struct CategoryMastery {
690    pub category: RecipeCategory,
691    pub current_xp: u32,
692    pub level: u32,
693    /// Recipe IDs unlocked through mastery thresholds.
694    pub unlocked_recipes: Vec<String>,
695}
696
697impl CategoryMastery {
698    pub fn new(category: RecipeCategory) -> Self {
699        Self {
700            category,
701            current_xp: 0,
702            level: 1,
703            unlocked_recipes: Vec::new(),
704        }
705    }
706
707    /// XP needed to reach the next level.
708    pub fn xp_to_next_level(&self) -> u32 {
709        (self.level * self.level * 100).max(100)
710    }
711
712    /// Add XP, returning true if a level-up occurred.
713    pub fn add_xp(&mut self, xp: u32) -> bool {
714        self.current_xp += xp;
715        let threshold = self.xp_to_next_level();
716        if self.current_xp >= threshold {
717            self.current_xp -= threshold;
718            self.level += 1;
719            return true;
720        }
721        false
722    }
723}
724
725/// Tracks crafting mastery across all categories and unlocks bonus recipes at thresholds.
726#[derive(Debug, Clone)]
727pub struct MasterySystem {
728    masteries: HashMap<String, CategoryMastery>,
729    /// Bonus recipes keyed by (category_label, required_level).
730    bonus_recipes: Vec<(String, u32, String)>,
731}
732
733impl MasterySystem {
734    pub fn new() -> Self {
735        let mut s = Self {
736            masteries: HashMap::new(),
737            bonus_recipes: Vec::new(),
738        };
739        // Initialize all categories
740        for cat in Self::all_categories() {
741            let label = cat.label().to_string();
742            s.masteries.insert(label, CategoryMastery::new(cat));
743        }
744        // Register bonus recipe unlock thresholds
745        s.bonus_recipes = vec![
746            ("Smithing".into(), 10, "iron_sword".into()),
747            ("Smithing".into(), 25, "steel_sword".into()),
748            ("Alchemy".into(), 5,  "health_potion_major".into()),
749            ("Alchemy".into(), 20, "poison_vial".into()),
750            ("Enchanting".into(), 15, "enchant_sharpness_i".into()),
751            ("Jeweling".into(), 10, "silver_ring".into()),
752        ];
753        s
754    }
755
756    fn all_categories() -> Vec<RecipeCategory> {
757        vec![
758            RecipeCategory::Smithing,
759            RecipeCategory::Alchemy,
760            RecipeCategory::Cooking,
761            RecipeCategory::Enchanting,
762            RecipeCategory::Engineering,
763            RecipeCategory::Tailoring,
764            RecipeCategory::Woodworking,
765            RecipeCategory::Jeweling,
766        ]
767    }
768
769    /// Award XP to a category, returning any newly unlocked recipe IDs.
770    pub fn award_xp(&mut self, category: &RecipeCategory, xp: u32) -> Vec<String> {
771        let key = category.label().to_string();
772        let mut newly_unlocked = Vec::new();
773        if let Some(mastery) = self.masteries.get_mut(&key) {
774            let leveled_up = mastery.add_xp(xp);
775            if leveled_up {
776                let new_level = mastery.level;
777                // Check all bonus recipes for this category
778                let unlocks: Vec<String> = self.bonus_recipes.iter()
779                    .filter(|(cat, thresh, _)| cat == &key && *thresh == new_level)
780                    .map(|(_, _, recipe_id)| recipe_id.clone())
781                    .collect();
782                for recipe_id in &unlocks {
783                    if let Some(m) = self.masteries.get_mut(&key) {
784                        if !m.unlocked_recipes.contains(recipe_id) {
785                            m.unlocked_recipes.push(recipe_id.clone());
786                        }
787                    }
788                }
789                newly_unlocked = unlocks;
790            }
791        }
792        newly_unlocked
793    }
794
795    /// Get the mastery for a category.
796    pub fn get_mastery(&self, category: &RecipeCategory) -> Option<&CategoryMastery> {
797        self.masteries.get(category.label())
798    }
799
800    /// Get the level for a category.
801    pub fn level(&self, category: &RecipeCategory) -> u32 {
802        self.masteries
803            .get(category.label())
804            .map(|m| m.level)
805            .unwrap_or(1)
806    }
807
808    /// Get all unlocked bonus recipe IDs for a category.
809    pub fn unlocked_recipes(&self, category: &RecipeCategory) -> Vec<String> {
810        self.masteries
811            .get(category.label())
812            .map(|m| m.unlocked_recipes.clone())
813            .unwrap_or_default()
814    }
815}
816
817impl Default for MasterySystem {
818    fn default() -> Self {
819        Self::new()
820    }
821}
822
823// ---------------------------------------------------------------------------
824// RecipeDiscovery — alchemy-style probabilistic discovery
825// ---------------------------------------------------------------------------
826
827/// Tracks which ingredient combinations a player has already attempted.
828#[derive(Debug, Clone)]
829pub struct RecipeDiscovery {
830    /// Set of sorted ingredient id lists that have been attempted.
831    attempted_combinations: Vec<Vec<String>>,
832    /// Recipes the player has already discovered.
833    discovered_recipe_ids: Vec<String>,
834}
835
836impl RecipeDiscovery {
837    pub fn new() -> Self {
838        Self {
839            attempted_combinations: Vec::new(),
840            discovered_recipe_ids: Vec::new(),
841        }
842    }
843
844    /// Attempt to discover a recipe from a list of ingredient ids.
845    ///
846    /// Returns `Some(recipe_id)` if the combination matches a discoverable recipe
847    /// AND the random roll succeeds.  `rng` should be in [0.0, 1.0).
848    pub fn attempt_discovery(
849        &mut self,
850        ingredient_ids: &[String],
851        recipe_book: &RecipeBook,
852        rng: f32,
853    ) -> Option<String> {
854        let mut sorted = ingredient_ids.to_vec();
855        sorted.sort();
856
857        // Already attempted this exact combination
858        if self.attempted_combinations.contains(&sorted) {
859            return None;
860        }
861        self.attempted_combinations.push(sorted.clone());
862
863        // Look for a matching recipe
864        let recipe = recipe_book.find_by_ingredients(ingredient_ids)?;
865
866        // Already discovered
867        if self.discovered_recipe_ids.contains(&recipe.id) {
868            return None;
869        }
870
871        // Recipe must have a positive discovery chance
872        if recipe.discovery_chance <= 0.0 {
873            return None;
874        }
875
876        // Roll the dice
877        if rng <= recipe.discovery_chance {
878            self.discovered_recipe_ids.push(recipe.id.clone());
879            return Some(recipe.id.clone());
880        }
881
882        None
883    }
884
885    /// Check whether a recipe has been discovered.
886    pub fn is_discovered(&self, recipe_id: &str) -> bool {
887        self.discovered_recipe_ids.iter().any(|id| id == recipe_id)
888    }
889
890    /// Force-add a recipe to discovered (e.g. from a recipe book item).
891    pub fn learn_recipe(&mut self, recipe_id: String) {
892        if !self.discovered_recipe_ids.contains(&recipe_id) {
893            self.discovered_recipe_ids.push(recipe_id);
894        }
895    }
896
897    /// All discovered recipe IDs.
898    pub fn all_discovered(&self) -> &[String] {
899        &self.discovered_recipe_ids
900    }
901}
902
903impl Default for RecipeDiscovery {
904    fn default() -> Self {
905        Self::new()
906    }
907}