Skip to main content

proof_engine/crafting/
workbench.rs

1// crafting/workbench.rs — Crafting station and job queue system
2
3use std::collections::HashMap;
4use glam::Vec3;
5use crate::crafting::recipes::{Recipe, CraftResult, CraftingCalculator};
6
7// ---------------------------------------------------------------------------
8// WorkbenchType
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum WorkbenchType {
13    Forge,
14    AlchemyTable,
15    CookingPot,
16    EnchantingTable,
17    Workbench,
18    Loom,
19    Jeweler,
20}
21
22impl WorkbenchType {
23    pub fn label(&self) -> &'static str {
24        match self {
25            WorkbenchType::Forge          => "Forge",
26            WorkbenchType::AlchemyTable   => "Alchemy Table",
27            WorkbenchType::CookingPot     => "Cooking Pot",
28            WorkbenchType::EnchantingTable => "Enchanting Table",
29            WorkbenchType::Workbench      => "Workbench",
30            WorkbenchType::Loom           => "Loom",
31            WorkbenchType::Jeweler        => "Jeweler",
32        }
33    }
34
35    /// Whether this bench type consumes fuel.
36    pub fn requires_fuel(&self) -> bool {
37        matches!(
38            self,
39            WorkbenchType::Forge | WorkbenchType::CookingPot
40        )
41    }
42
43    /// Base quality bonus granted by this bench type.
44    pub fn base_quality_bonus(&self) -> u32 {
45        match self {
46            WorkbenchType::Forge          => 5,
47            WorkbenchType::AlchemyTable   => 8,
48            WorkbenchType::CookingPot     => 3,
49            WorkbenchType::EnchantingTable => 12,
50            WorkbenchType::Workbench      => 4,
51            WorkbenchType::Loom           => 4,
52            WorkbenchType::Jeweler        => 10,
53        }
54    }
55}
56
57// ---------------------------------------------------------------------------
58// WorkbenchTier
59// ---------------------------------------------------------------------------
60
61#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
62pub enum WorkbenchTier {
63    Basic,
64    Improved,
65    Advanced,
66    Master,
67}
68
69impl WorkbenchTier {
70    /// Quality bonus multiplier on top of WorkbenchType's base bonus.
71    pub fn quality_multiplier(&self) -> f32 {
72        match self {
73            WorkbenchTier::Basic    => 1.0,
74            WorkbenchTier::Improved => 1.25,
75            WorkbenchTier::Advanced => 1.60,
76            WorkbenchTier::Master   => 2.20,
77        }
78    }
79
80    /// Speed multiplier (higher = faster crafting).
81    pub fn speed_multiplier(&self) -> f32 {
82        match self {
83            WorkbenchTier::Basic    => 1.0,
84            WorkbenchTier::Improved => 1.15,
85            WorkbenchTier::Advanced => 1.35,
86            WorkbenchTier::Master   => 1.60,
87        }
88    }
89
90    /// Cost in gold to upgrade to the next tier.
91    pub fn upgrade_cost(&self) -> u64 {
92        match self {
93            WorkbenchTier::Basic    => 500,
94            WorkbenchTier::Improved => 2000,
95            WorkbenchTier::Advanced => 8000,
96            WorkbenchTier::Master   => 0, // already max
97        }
98    }
99
100    pub fn label(&self) -> &'static str {
101        match self {
102            WorkbenchTier::Basic    => "Basic",
103            WorkbenchTier::Improved => "Improved",
104            WorkbenchTier::Advanced => "Advanced",
105            WorkbenchTier::Master   => "Master",
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// WorkbenchState
112// ---------------------------------------------------------------------------
113
114#[derive(Debug, Clone)]
115pub enum WorkbenchState {
116    Idle,
117    Crafting {
118        job_id: u64,
119        elapsed: f32,
120        duration: f32,
121    },
122    Broken {
123        repair_cost: u64,
124    },
125}
126
127impl WorkbenchState {
128    pub fn is_idle(&self) -> bool {
129        matches!(self, WorkbenchState::Idle)
130    }
131
132    pub fn is_crafting(&self) -> bool {
133        matches!(self, WorkbenchState::Crafting { .. })
134    }
135
136    pub fn is_broken(&self) -> bool {
137        matches!(self, WorkbenchState::Broken { .. })
138    }
139}
140
141// ---------------------------------------------------------------------------
142// CraftingJob
143// ---------------------------------------------------------------------------
144
145/// A single crafting task in the queue.
146#[derive(Debug, Clone)]
147pub struct CraftingJob {
148    pub id: u64,
149    pub recipe_id: String,
150    /// Ingredients consumed when this job started, as (item_id, quantity).
151    pub ingredients_consumed: Vec<(String, u32)>,
152    /// Timestamp (game time seconds) when this job started.
153    pub started_at: f32,
154    /// Total duration of this job in seconds.
155    pub duration: f32,
156    /// How many craft cycles to run (batch crafting).
157    pub quantity: u32,
158    /// Player/entity that owns this job.
159    pub owner_id: String,
160}
161
162impl CraftingJob {
163    pub fn new(
164        id: u64,
165        recipe_id: impl Into<String>,
166        ingredients_consumed: Vec<(String, u32)>,
167        started_at: f32,
168        duration: f32,
169        quantity: u32,
170        owner_id: impl Into<String>,
171    ) -> Self {
172        Self {
173            id,
174            recipe_id: recipe_id.into(),
175            ingredients_consumed,
176            started_at,
177            duration,
178            quantity,
179            owner_id: owner_id.into(),
180        }
181    }
182
183    /// Elapsed progress as a fraction [0.0, 1.0].
184    pub fn progress(&self, current_time: f32) -> f32 {
185        if self.duration <= 0.0 {
186            return 1.0;
187        }
188        ((current_time - self.started_at) / self.duration).clamp(0.0, 1.0)
189    }
190
191    /// Whether the job is complete given current time.
192    pub fn is_complete(&self, current_time: f32) -> bool {
193        current_time >= self.started_at + self.duration
194    }
195}
196
197// ---------------------------------------------------------------------------
198// CraftingQueue
199// ---------------------------------------------------------------------------
200
201const MAX_QUEUE_SLOTS: usize = 8;
202
203/// Ordered pending job queue with a maximum of 8 slots.
204#[derive(Debug, Clone)]
205pub struct CraftingQueue {
206    jobs: Vec<CraftingJob>,
207    next_job_id: u64,
208}
209
210impl CraftingQueue {
211    pub fn new() -> Self {
212        Self {
213            jobs: Vec::with_capacity(MAX_QUEUE_SLOTS),
214            next_job_id: 1,
215        }
216    }
217
218    /// Whether the queue has room for another job.
219    pub fn has_capacity(&self) -> bool {
220        self.jobs.len() < MAX_QUEUE_SLOTS
221    }
222
223    /// Number of jobs currently in the queue.
224    pub fn len(&self) -> usize {
225        self.jobs.len()
226    }
227
228    pub fn is_empty(&self) -> bool {
229        self.jobs.is_empty()
230    }
231
232    /// Enqueue a new job, returning its id or None if the queue is full.
233    pub fn enqueue(
234        &mut self,
235        recipe_id: impl Into<String>,
236        ingredients_consumed: Vec<(String, u32)>,
237        started_at: f32,
238        duration: f32,
239        quantity: u32,
240        owner_id: impl Into<String>,
241    ) -> Option<u64> {
242        if !self.has_capacity() {
243            return None;
244        }
245        let id = self.next_job_id;
246        self.next_job_id += 1;
247        self.jobs.push(CraftingJob::new(
248            id,
249            recipe_id,
250            ingredients_consumed,
251            started_at,
252            duration,
253            quantity,
254            owner_id,
255        ));
256        Some(id)
257    }
258
259    /// Peek at the front job without removing it.
260    pub fn front(&self) -> Option<&CraftingJob> {
261        self.jobs.first()
262    }
263
264    /// Dequeue the front job.
265    pub fn dequeue(&mut self) -> Option<CraftingJob> {
266        if self.jobs.is_empty() {
267            None
268        } else {
269            Some(self.jobs.remove(0))
270        }
271    }
272
273    /// Remove a job by id (e.g. on cancellation).
274    pub fn cancel(&mut self, job_id: u64) -> Option<CraftingJob> {
275        if let Some(pos) = self.jobs.iter().position(|j| j.id == job_id) {
276            Some(self.jobs.remove(pos))
277        } else {
278            None
279        }
280    }
281
282    /// All jobs currently in the queue.
283    pub fn all_jobs(&self) -> &[CraftingJob] {
284        &self.jobs
285    }
286}
287
288impl Default for CraftingQueue {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294// ---------------------------------------------------------------------------
295// WorkbenchEvent
296// ---------------------------------------------------------------------------
297
298#[derive(Debug, Clone)]
299pub enum WorkbenchEvent {
300    JobStarted {
301        job_id: u64,
302        recipe_id: String,
303    },
304    JobCompleted {
305        job_id: u64,
306        results: Vec<(String, u32, u8)>, // (item_id, quantity, quality)
307    },
308    JobFailed {
309        job_id: u64,
310        reason: String,
311    },
312    FuelEmpty,
313    RepairNeeded {
314        repair_cost: u64,
315    },
316    QueueFull,
317}
318
319// ---------------------------------------------------------------------------
320// FuelType and FuelSystem
321// ---------------------------------------------------------------------------
322
323#[derive(Debug, Clone, PartialEq, Eq, Hash)]
324pub enum FuelType {
325    Coal,
326    Wood,
327    MagicCrystal,
328}
329
330impl FuelType {
331    /// Burn rate in fuel units consumed per second of crafting.
332    pub fn burn_rate(&self) -> f32 {
333        match self {
334            FuelType::Coal         => 0.5,
335            FuelType::Wood         => 1.0,
336            FuelType::MagicCrystal => 0.1,
337        }
338    }
339
340    /// Extra heat bonus that increases quality when using this fuel.
341    pub fn heat_quality_bonus(&self) -> u32 {
342        match self {
343            FuelType::Coal         => 3,
344            FuelType::Wood         => 1,
345            FuelType::MagicCrystal => 15,
346        }
347    }
348
349    /// Fuel units per physical item of this type.
350    pub fn fuel_value(&self) -> f32 {
351        match self {
352            FuelType::Coal         => 60.0,
353            FuelType::Wood         => 20.0,
354            FuelType::MagicCrystal => 300.0,
355        }
356    }
357
358    pub fn label(&self) -> &'static str {
359        match self {
360            FuelType::Coal         => "Coal",
361            FuelType::Wood         => "Wood",
362            FuelType::MagicCrystal => "Magic Crystal",
363        }
364    }
365}
366
367/// Manages fuel for a workbench that requires heat.
368#[derive(Debug, Clone)]
369pub struct FuelSystem {
370    pub fuel_level: f32,
371    pub max_fuel: f32,
372    pub current_fuel_type: FuelType,
373    pub is_burning: bool,
374}
375
376impl FuelSystem {
377    pub fn new(max_fuel: f32) -> Self {
378        Self {
379            fuel_level: 0.0,
380            max_fuel,
381            current_fuel_type: FuelType::Coal,
382            is_burning: false,
383        }
384    }
385
386    /// Add fuel items of a given type.  Returns overflow (items that wouldn't fit).
387    pub fn add_fuel(&mut self, fuel_type: FuelType, items: u32) -> u32 {
388        let units = fuel_type.fuel_value() * items as f32;
389        let available_space = self.max_fuel - self.fuel_level;
390        if units <= available_space {
391            self.fuel_level += units;
392            self.current_fuel_type = fuel_type;
393            0
394        } else {
395            self.fuel_level = self.max_fuel;
396            let fuel_value = fuel_type.fuel_value();
397            self.current_fuel_type = fuel_type;
398            let overflow_units = units - available_space;
399            let overflow_items = (overflow_units / fuel_value).ceil() as u32;
400            overflow_items
401        }
402    }
403
404    /// Consume fuel for `dt` seconds of crafting. Returns true if fuel remains.
405    pub fn consume(&mut self, dt: f32) -> bool {
406        if !self.is_burning {
407            return true;
408        }
409        let consumed = self.current_fuel_type.burn_rate() * dt;
410        if self.fuel_level >= consumed {
411            self.fuel_level -= consumed;
412            true
413        } else {
414            self.fuel_level = 0.0;
415            self.is_burning = false;
416            false
417        }
418    }
419
420    /// Current heat quality bonus (0 if no fuel).
421    pub fn heat_quality_bonus(&self) -> u32 {
422        if self.is_burning && self.fuel_level > 0.0 {
423            self.current_fuel_type.heat_quality_bonus()
424        } else {
425            0
426        }
427    }
428
429    pub fn ignite(&mut self) {
430        if self.fuel_level > 0.0 {
431            self.is_burning = true;
432        }
433    }
434
435    pub fn extinguish(&mut self) {
436        self.is_burning = false;
437    }
438
439    pub fn is_empty(&self) -> bool {
440        self.fuel_level <= 0.0
441    }
442
443    /// Fuel as a fraction of max [0.0, 1.0].
444    pub fn level_fraction(&self) -> f32 {
445        if self.max_fuel <= 0.0 {
446            return 0.0;
447        }
448        (self.fuel_level / self.max_fuel).clamp(0.0, 1.0)
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Workbench
454// ---------------------------------------------------------------------------
455
456/// A single crafting station in the world.
457#[derive(Debug, Clone)]
458pub struct Workbench {
459    pub id: u64,
460    pub position: Vec3,
461    pub bench_type: WorkbenchType,
462    pub tier: WorkbenchTier,
463    pub state: WorkbenchState,
464    pub queue: CraftingQueue,
465    pub fuel: FuelSystem,
466    /// Overall efficiency multiplier (0.0–2.0), affected by repairs and upgrades.
467    pub efficiency: f32,
468    /// Accumulated wear (0.0–1.0). At 1.0 the bench breaks.
469    pub wear: f32,
470    /// Skill level assumed for quality calculations (e.g. owner's skill).
471    pub operator_skill: u32,
472    /// Pending events generated during tick().
473    events_buffer: Vec<WorkbenchEvent>,
474    /// Game time tracker (seconds since session start).
475    current_time: f32,
476}
477
478impl Workbench {
479    pub fn new(id: u64, position: Vec3, bench_type: WorkbenchType, tier: WorkbenchTier) -> Self {
480        let needs_fuel = bench_type.requires_fuel();
481        let max_fuel = if needs_fuel { 600.0 } else { 0.0 };
482        Self {
483            id,
484            position,
485            bench_type,
486            tier,
487            state: WorkbenchState::Idle,
488            queue: CraftingQueue::new(),
489            fuel: FuelSystem::new(max_fuel),
490            efficiency: 1.0,
491            wear: 0.0,
492            operator_skill: 1,
493            events_buffer: Vec::new(),
494            current_time: 0.0,
495        }
496    }
497
498    /// Try to start the next queued job.
499    fn try_start_next_job(&mut self) {
500        if !self.state.is_idle() {
501            return;
502        }
503        if self.state.is_broken() {
504            return;
505        }
506        if let Some(job) = self.queue.front() {
507            let job_id = job.id;
508            let duration = job.duration;
509            let recipe_id = job.recipe_id.clone();
510            self.state = WorkbenchState::Crafting {
511                job_id,
512                elapsed: 0.0,
513                duration,
514            };
515            self.events_buffer.push(WorkbenchEvent::JobStarted { job_id, recipe_id });
516
517            // Ignite fuel if needed
518            if self.bench_type.requires_fuel() {
519                self.fuel.ignite();
520            }
521        }
522    }
523
524    /// Advance the workbench by `dt` seconds.
525    pub fn tick(&mut self, dt: f32) -> Vec<WorkbenchEvent> {
526        self.events_buffer.clear();
527        self.current_time += dt;
528
529        if self.state.is_broken() {
530            return self.events_buffer.clone();
531        }
532
533        // Try to start a job if idle and queue has items
534        if self.state.is_idle() && !self.queue.is_empty() {
535            self.try_start_next_job();
536        }
537
538        match self.state.clone() {
539            WorkbenchState::Idle => {}
540            WorkbenchState::Broken { .. } => {}
541            WorkbenchState::Crafting { job_id, elapsed, duration } => {
542                // Consume fuel if required
543                if self.bench_type.requires_fuel() {
544                    let fuel_ok = self.fuel.consume(dt);
545                    if !fuel_ok {
546                        // Out of fuel — pause the job (keep it in queue, go idle)
547                        self.state = WorkbenchState::Idle;
548                        self.events_buffer.push(WorkbenchEvent::FuelEmpty);
549                        return self.events_buffer.clone();
550                    }
551                }
552
553                let new_elapsed = (elapsed + dt * self.efficiency).min(duration);
554                self.state = WorkbenchState::Crafting {
555                    job_id,
556                    elapsed: new_elapsed,
557                    duration,
558                };
559
560                // Accumulate wear
561                self.wear += dt * 0.0002;
562                if self.wear >= 1.0 {
563                    self.wear = 1.0;
564                    let repair_cost = self.compute_repair_cost();
565                    self.state = WorkbenchState::Broken { repair_cost };
566                    self.events_buffer.push(WorkbenchEvent::RepairNeeded { repair_cost });
567                    return self.events_buffer.clone();
568                }
569
570                // Check completion
571                if new_elapsed >= duration {
572                    // Dequeue the job
573                    if let Some(job) = self.queue.dequeue() {
574                        let results = self.compute_job_results(&job);
575                        self.events_buffer.push(WorkbenchEvent::JobCompleted {
576                            job_id: job.id,
577                            results,
578                        });
579                    }
580                    self.state = WorkbenchState::Idle;
581                    // Immediately try to start the next queued job
582                    self.try_start_next_job();
583                }
584            }
585        }
586
587        self.events_buffer.clone()
588    }
589
590    /// Compute output items for a completed job.
591    fn compute_job_results(&self, job: &CraftingJob) -> Vec<(String, u32, u8)> {
592        let quality_bonus = self.tier_quality_bonus();
593        let fuel_bonus = self.fuel.heat_quality_bonus();
594        let mut results = Vec::new();
595
596        // For simplicity we generate results based on job.quantity iterations
597        for _ in 0..job.quantity {
598            // Base quality per result is encoded in the recipe; we use a default of 80
599            let base_quality: u8 = 80;
600            let computed_quality = CraftingCalculator::calculate_quality(
601                base_quality,
602                self.operator_skill,
603                quality_bonus + fuel_bonus,
604            );
605            let item_id = format!("{}_product", job.recipe_id);
606            results.push((item_id, 1, computed_quality));
607        }
608        results
609    }
610
611    /// Compute results for a job using a known recipe.
612    pub fn compute_results_for_recipe(
613        &self,
614        recipe: &Recipe,
615        quantity: u32,
616        rng_values: &[f32],
617    ) -> Vec<(String, u32, u8)> {
618        let quality_bonus = self.tier_quality_bonus();
619        let fuel_bonus = self.fuel.heat_quality_bonus();
620        let mut results = Vec::new();
621        let mut rng_idx = 0;
622
623        for _ in 0..quantity {
624            for craft_result in &recipe.results {
625                let rng = rng_values.get(rng_idx).copied().unwrap_or(0.5);
626                rng_idx += 1;
627
628                if CraftingCalculator::evaluate_chance(craft_result, self.operator_skill, rng) {
629                    let computed_quality = CraftingCalculator::calculate_quality(
630                        craft_result.quality,
631                        self.operator_skill,
632                        quality_bonus + fuel_bonus,
633                    );
634                    let computed_quantity = CraftingCalculator::calculate_quantity(
635                        craft_result.quantity,
636                        self.operator_skill,
637                        0.0,
638                    );
639                    results.push((craft_result.item_id.clone(), computed_quantity, computed_quality));
640                }
641            }
642        }
643        results
644    }
645
646    /// Effective quality bonus considering tier and bench type.
647    pub fn tier_quality_bonus(&self) -> u32 {
648        let base = self.bench_type.base_quality_bonus();
649        let multiplied = base as f32 * self.tier.quality_multiplier();
650        multiplied.round() as u32
651    }
652
653    /// Submit a new crafting job to the queue.
654    ///
655    /// Returns the job_id on success, or an event explaining why it failed.
656    pub fn submit_job(
657        &mut self,
658        recipe_id: impl Into<String>,
659        ingredients: Vec<(String, u32)>,
660        duration: f32,
661        quantity: u32,
662        owner_id: impl Into<String>,
663    ) -> Result<u64, WorkbenchEvent> {
664        if self.state.is_broken() {
665            let repair_cost = match &self.state {
666                WorkbenchState::Broken { repair_cost } => *repair_cost,
667                _ => 0,
668            };
669            return Err(WorkbenchEvent::RepairNeeded { repair_cost });
670        }
671        if !self.queue.has_capacity() {
672            return Err(WorkbenchEvent::QueueFull);
673        }
674        let adjusted_duration = duration / self.tier.speed_multiplier();
675        let job_id = self.queue.enqueue(
676            recipe_id,
677            ingredients,
678            self.current_time,
679            adjusted_duration,
680            quantity,
681            owner_id,
682        );
683        match job_id {
684            Some(id) => {
685                self.try_start_next_job();
686                Ok(id)
687            }
688            None => Err(WorkbenchEvent::QueueFull),
689        }
690    }
691
692    /// Repair a broken workbench (costs gold handled externally).
693    pub fn repair(&mut self) {
694        self.wear = 0.0;
695        self.state = WorkbenchState::Idle;
696        self.efficiency = 1.0;
697    }
698
699    /// Compute repair cost based on tier and wear level.
700    pub fn compute_repair_cost(&self) -> u64 {
701        let base: u64 = match self.tier {
702            WorkbenchTier::Basic    => 100,
703            WorkbenchTier::Improved => 400,
704            WorkbenchTier::Advanced => 1200,
705            WorkbenchTier::Master   => 3500,
706        };
707        let wear_factor = (self.wear * 3.0) as u64;
708        base + wear_factor * 10
709    }
710
711    /// Attempt to upgrade the bench to the next tier.  Returns the cost or None if already Master.
712    pub fn upgrade_cost(&self) -> Option<u64> {
713        match self.tier {
714            WorkbenchTier::Master => None,
715            _ => Some(self.tier.upgrade_cost()),
716        }
717    }
718
719    /// Perform the upgrade (payment handled externally).
720    pub fn upgrade(&mut self) {
721        self.tier = match self.tier {
722            WorkbenchTier::Basic    => WorkbenchTier::Improved,
723            WorkbenchTier::Improved => WorkbenchTier::Advanced,
724            WorkbenchTier::Advanced => WorkbenchTier::Master,
725            WorkbenchTier::Master   => WorkbenchTier::Master,
726        };
727    }
728
729    /// Cancel a queued job by id, returning the job if found.
730    pub fn cancel_job(&mut self, job_id: u64) -> Option<CraftingJob> {
731        // If it's the active job, stop crafting
732        if let WorkbenchState::Crafting { job_id: active_id, .. } = &self.state {
733            if *active_id == job_id {
734                self.state = WorkbenchState::Idle;
735            }
736        }
737        self.queue.cancel(job_id)
738    }
739
740    /// Progress of the current job [0.0, 1.0], or None if idle/broken.
741    pub fn current_progress(&self) -> Option<f32> {
742        match &self.state {
743            WorkbenchState::Crafting { elapsed, duration, .. } => {
744                if *duration <= 0.0 {
745                    Some(1.0)
746                } else {
747                    Some((elapsed / duration).clamp(0.0, 1.0))
748                }
749            }
750            _ => None,
751        }
752    }
753}
754
755// ---------------------------------------------------------------------------
756// CraftingStation — group of workbenches at one location
757// ---------------------------------------------------------------------------
758
759/// A named location containing multiple workbenches with a shared inventory.
760#[derive(Debug, Clone)]
761pub struct CraftingStation {
762    pub id: u64,
763    pub name: String,
764    pub position: Vec3,
765    pub benches: Vec<Workbench>,
766    /// Shared item inventory: item_id -> quantity.
767    pub inventory: HashMap<String, u32>,
768}
769
770impl CraftingStation {
771    pub fn new(id: u64, name: impl Into<String>, position: Vec3) -> Self {
772        Self {
773            id,
774            name: name.into(),
775            position,
776            benches: Vec::new(),
777            inventory: HashMap::new(),
778        }
779    }
780
781    /// Add a workbench to the station.
782    pub fn add_bench(&mut self, bench: Workbench) {
783        self.benches.push(bench);
784    }
785
786    /// Add items to the shared inventory.
787    pub fn add_item(&mut self, item_id: impl Into<String>, quantity: u32) {
788        *self.inventory.entry(item_id.into()).or_insert(0) += quantity;
789    }
790
791    /// Remove items from inventory. Returns false if not enough stock.
792    pub fn remove_item(&mut self, item_id: &str, quantity: u32) -> bool {
793        if let Some(stock) = self.inventory.get_mut(item_id) {
794            if *stock >= quantity {
795                *stock -= quantity;
796                return true;
797            }
798        }
799        false
800    }
801
802    /// How many of an item are in inventory.
803    pub fn item_count(&self, item_id: &str) -> u32 {
804        self.inventory.get(item_id).copied().unwrap_or(0)
805    }
806
807    /// Tick all benches, collecting events.
808    pub fn tick(&mut self, dt: f32) -> Vec<(u64, WorkbenchEvent)> {
809        let mut all_events = Vec::new();
810        for bench in &mut self.benches {
811            let bench_id = bench.id;
812            let events = bench.tick(dt);
813            for event in events {
814                // If a job completed, deposit results into shared inventory
815                if let WorkbenchEvent::JobCompleted { ref results, .. } = event {
816                    for (item_id, qty, _quality) in results {
817                        *self.inventory.entry(item_id.clone()).or_insert(0) += qty;
818                    }
819                }
820                all_events.push((bench_id, event));
821            }
822        }
823        all_events
824    }
825
826    /// Find a workbench of a given type.
827    pub fn find_bench_of_type(&self, bench_type: &WorkbenchType) -> Option<&Workbench> {
828        self.benches.iter().find(|b| &b.bench_type == bench_type)
829    }
830
831    /// Find a mutable workbench of a given type.
832    pub fn find_bench_of_type_mut(&mut self, bench_type: &WorkbenchType) -> Option<&mut Workbench> {
833        self.benches.iter_mut().find(|b| &b.bench_type == bench_type)
834    }
835}
836
837// ---------------------------------------------------------------------------
838// AutoCrafter — AI-driven batch production loop
839// ---------------------------------------------------------------------------
840
841/// Configuration for an AutoCrafter run.
842#[derive(Debug, Clone)]
843pub struct AutoCraftConfig {
844    pub recipe_id: String,
845    pub target_quantity: u32,
846    pub owner_id: String,
847    /// Minimum inventory of the output item before stopping.
848    pub stop_at_stock: u32,
849}
850
851impl AutoCraftConfig {
852    pub fn new(
853        recipe_id: impl Into<String>,
854        target_quantity: u32,
855        owner_id: impl Into<String>,
856    ) -> Self {
857        Self {
858            recipe_id: recipe_id.into(),
859            target_quantity,
860            owner_id: owner_id.into(),
861            stop_at_stock: u32::MAX,
862        }
863    }
864}
865
866/// Drives a CraftingStation to produce items automatically.
867#[derive(Debug, Clone)]
868pub struct AutoCrafter {
869    pub config: AutoCraftConfig,
870    pub produced: u32,
871    pub running: bool,
872    pub last_submit_time: f32,
873    /// Minimum seconds between job submissions.
874    pub submit_interval: f32,
875}
876
877impl AutoCrafter {
878    pub fn new(config: AutoCraftConfig) -> Self {
879        Self {
880            config,
881            produced: 0,
882            running: true,
883            last_submit_time: 0.0,
884            submit_interval: 1.0,
885        }
886    }
887
888    /// Tick the auto crafter, submitting jobs to the station as capacity allows.
889    ///
890    /// Returns the number of new jobs submitted.
891    pub fn tick(
892        &mut self,
893        current_time: f32,
894        station: &mut CraftingStation,
895        recipe_duration: f32,
896        ingredients: Vec<(String, u32)>,
897    ) -> u32 {
898        if !self.running {
899            return 0;
900        }
901        if self.produced >= self.config.target_quantity {
902            self.running = false;
903            return 0;
904        }
905        if current_time - self.last_submit_time < self.submit_interval {
906            return 0;
907        }
908
909        // Check output stock stop condition
910        let recipe_output_key = format!("{}_product", self.config.recipe_id);
911        let current_stock = station.item_count(&recipe_output_key);
912        if current_stock >= self.config.stop_at_stock {
913            self.running = false;
914            return 0;
915        }
916
917        let mut submitted = 0u32;
918        let bench_count = station.benches.len();
919        let mut i = 0;
920        while i < bench_count {
921            if self.produced >= self.config.target_quantity { break; }
922            let remaining = self.config.target_quantity - self.produced;
923            if !station.benches[i].queue.has_capacity() { i += 1; continue; }
924            let can_craft = ingredients.iter().all(|(item_id, qty)| {
925                station.inventory.get(item_id).copied().unwrap_or(0) >= *qty
926            });
927            if !can_craft { break; }
928            for (item_id, qty) in &ingredients {
929                if let Some(stock) = station.inventory.get_mut(item_id) {
930                    *stock = stock.saturating_sub(*qty);
931                }
932            }
933            let batch = remaining.min(8);
934            let result = station.benches[i].submit_job(
935                self.config.recipe_id.clone(),
936                ingredients.clone(),
937                recipe_duration,
938                batch,
939                self.config.owner_id.clone(),
940            );
941            if result.is_ok() {
942                self.produced += batch;
943                submitted += 1;
944                self.last_submit_time = current_time;
945            }
946            i += 1;
947        }
948        submitted
949    }
950
951    pub fn stop(&mut self) {
952        self.running = false;
953    }
954
955    pub fn restart(&mut self) {
956        self.running = true;
957        self.produced = 0;
958    }
959}