Skip to main content

proof_engine/economy/
production.rs

1//! Production chain simulation.
2//!
3//! Models resource extraction nodes (mines, farms, forests), multi-stage
4//! processing buildings that transform inputs to outputs, worker assignment,
5//! production quotas, efficiency modifiers, supply chain disruptions, and
6//! per-node stockpile management.
7
8use std::collections::{HashMap, VecDeque};
9
10// ---------------------------------------------------------------------------
11// IDs
12// ---------------------------------------------------------------------------
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct NodeId(pub u32);
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct BuildingId(pub u32);
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct WorkerId(pub u32);
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct RecipeId(pub u32);
25
26/// Commodity reference (mirrors market::CommodityId but avoids cross-module dependency).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct CommodityRef(pub u32);
29
30// ---------------------------------------------------------------------------
31// Resource Nodes
32// ---------------------------------------------------------------------------
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum NodeKind {
36    Mine,
37    Farm,
38    Forest,
39    Quarry,
40    Fishery,
41    OilWell,
42    HerbGarden,
43}
44
45impl NodeKind {
46    /// Base yield variance (±fraction of base yield).
47    pub fn yield_variance(&self) -> f64 {
48        match self {
49            NodeKind::Mine => 0.05,
50            NodeKind::Farm => 0.20,
51            NodeKind::Forest => 0.10,
52            NodeKind::Quarry => 0.03,
53            NodeKind::Fishery => 0.25,
54            NodeKind::OilWell => 0.08,
55            NodeKind::HerbGarden => 0.15,
56        }
57    }
58
59    /// Natural depletion rate per tick (fraction of reserves consumed).
60    pub fn depletion_rate(&self) -> f64 {
61        match self {
62            NodeKind::Mine => 0.001,
63            NodeKind::Farm => 0.0,       // farms are renewable
64            NodeKind::Forest => 0.0002,
65            NodeKind::Quarry => 0.0008,
66            NodeKind::Fishery => 0.0015,
67            NodeKind::OilWell => 0.002,
68            NodeKind::HerbGarden => 0.0,
69        }
70    }
71
72    /// Whether this node is renewable (deplete_rate ignored for renewal calculations).
73    pub fn is_renewable(&self) -> bool {
74        matches!(self, NodeKind::Farm | NodeKind::HerbGarden)
75    }
76}
77
78/// A resource extraction node in the world.
79#[derive(Debug, Clone)]
80pub struct ResourceNode {
81    pub id: NodeId,
82    pub name: String,
83    pub kind: NodeKind,
84    /// The commodity this node produces.
85    pub output_commodity: CommodityRef,
86    /// Base output per worker per tick.
87    pub base_yield_per_worker: f64,
88    /// Remaining reserves (None = infinite for renewable).
89    pub reserves: Option<f64>,
90    /// Maximum reserves (for computing depletion fraction).
91    pub max_reserves: Option<f64>,
92    /// Workers currently assigned.
93    pub workers_assigned: u32,
94    /// Maximum worker capacity.
95    pub worker_capacity: u32,
96    /// Accumulated stockpile (output not yet transferred).
97    pub stockpile: f64,
98    /// Maximum stockpile before output is lost.
99    pub stockpile_capacity: f64,
100    /// Current efficiency (0.0 – 1.0 plus bonuses).
101    pub efficiency: f64,
102    /// Degradation: amount efficiency decays per tick without maintenance.
103    pub degradation_rate: f64,
104    /// Ticks until scheduled maintenance (0 = needs maintenance).
105    pub maintenance_due_in: u64,
106    /// Disruption severity (0 = none, 1 = fully halted).
107    pub disruption: f64,
108    /// Season modifier (1.0 = normal, farm might be 0.0 in winter).
109    pub season_modifier: f64,
110    /// Output history ring buffer (last 64 ticks).
111    pub output_history: VecDeque<f64>,
112    /// Whether this node is active.
113    pub active: bool,
114}
115
116impl ResourceNode {
117    pub fn new(
118        id: NodeId,
119        name: &str,
120        kind: NodeKind,
121        output_commodity: CommodityRef,
122        base_yield_per_worker: f64,
123        reserves: Option<f64>,
124        worker_capacity: u32,
125        stockpile_capacity: f64,
126    ) -> Self {
127        let max_reserves = reserves;
128        Self {
129            id,
130            name: name.to_string(),
131            kind,
132            output_commodity,
133            base_yield_per_worker,
134            reserves,
135            max_reserves,
136            workers_assigned: 0,
137            worker_capacity,
138            stockpile: 0.0,
139            stockpile_capacity,
140            efficiency: 1.0,
141            degradation_rate: 0.001,
142            maintenance_due_in: 200,
143            disruption: 0.0,
144            season_modifier: 1.0,
145            output_history: VecDeque::with_capacity(64),
146            active: true,
147        }
148    }
149
150    /// Assign up to `count` additional workers. Returns how many were actually assigned.
151    pub fn assign_workers(&mut self, count: u32) -> u32 {
152        let available = self.worker_capacity.saturating_sub(self.workers_assigned);
153        let added = count.min(available);
154        self.workers_assigned += added;
155        added
156    }
157
158    /// Remove workers.
159    pub fn remove_workers(&mut self, count: u32) {
160        self.workers_assigned = self.workers_assigned.saturating_sub(count);
161    }
162
163    /// Compute gross output for this tick before stockpile and reserve limits.
164    pub fn compute_output(&self, rng_variance: f64) -> f64 {
165        if !self.active || self.workers_assigned == 0 { return 0.0; }
166        // Reserves check
167        if let Some(r) = self.reserves {
168            if r <= 0.0 { return 0.0; }
169        }
170        let variance = self.kind.yield_variance();
171        let noise = 1.0 + (rng_variance - 0.5) * 2.0 * variance;
172        let gross = self.base_yield_per_worker
173            * self.workers_assigned as f64
174            * self.efficiency
175            * self.season_modifier
176            * (1.0 - self.disruption)
177            * noise;
178        gross.max(0.0)
179    }
180
181    /// Apply output to the stockpile and consume reserves. Returns actual output.
182    pub fn tick_output(&mut self, rng_variance: f64) -> f64 {
183        let gross = self.compute_output(rng_variance);
184        // Deplete reserves
185        if let Some(r) = self.reserves.as_mut() {
186            let consumed = gross * self.kind.depletion_rate() * 500.0; // scaling factor
187            *r = (*r - consumed).max(0.0);
188        }
189        // Add to stockpile (capped)
190        let actual = (self.stockpile + gross).min(self.stockpile_capacity);
191        let produced = actual - self.stockpile;
192        self.stockpile = actual;
193        // History
194        self.output_history.push_back(produced);
195        if self.output_history.len() > 64 { self.output_history.pop_front(); }
196        // Degrade efficiency
197        self.efficiency = (self.efficiency - self.degradation_rate).max(0.1);
198        if self.maintenance_due_in > 0 { self.maintenance_due_in -= 1; }
199        produced
200    }
201
202    /// Withdraw up to `amount` from the stockpile.
203    pub fn withdraw(&mut self, amount: f64) -> f64 {
204        let taken = amount.min(self.stockpile);
205        self.stockpile -= taken;
206        taken
207    }
208
209    /// Perform maintenance: restore efficiency.
210    pub fn perform_maintenance(&mut self) {
211        self.efficiency = (self.efficiency + 0.3).min(1.2); // can briefly exceed 1.0 after maintenance
212        self.maintenance_due_in = 200;
213    }
214
215    /// Apply a disruption event.
216    pub fn apply_disruption(&mut self, severity: f64) {
217        self.disruption = (self.disruption + severity).min(1.0);
218    }
219
220    /// Recover from disruption over time.
221    pub fn recover_disruption(&mut self, rate: f64) {
222        self.disruption = (self.disruption - rate).max(0.0);
223    }
224
225    /// Average output over last n ticks.
226    pub fn avg_output(&self, n: usize) -> f64 {
227        let slice: Vec<_> = self.output_history.iter().rev().take(n).collect();
228        if slice.is_empty() { return 0.0; }
229        slice.iter().copied().sum::<f64>() / slice.len() as f64
230    }
231
232    /// Reserve depletion fraction (0 = full, 1 = empty). None if unlimited.
233    pub fn depletion_fraction(&self) -> Option<f64> {
234        let r = self.reserves?;
235        let max = self.max_reserves?;
236        if max < 1e-9 { return Some(1.0); }
237        Some(1.0 - r / max)
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Recipes
243// ---------------------------------------------------------------------------
244
245/// An ingredient in a recipe.
246#[derive(Debug, Clone)]
247pub struct Ingredient {
248    pub commodity: CommodityRef,
249    pub amount: f64,
250}
251
252/// A production recipe: consumes inputs to produce outputs.
253#[derive(Debug, Clone)]
254pub struct Recipe {
255    pub id: RecipeId,
256    pub name: String,
257    pub inputs: Vec<Ingredient>,
258    pub outputs: Vec<Ingredient>,
259    /// Base ticks required to complete one batch.
260    pub base_ticks: u64,
261    /// How many workers are required per batch.
262    pub workers_required: u32,
263    /// Power/energy units required per batch.
264    pub energy_required: f64,
265}
266
267impl Recipe {
268    /// Check if the given input quantities are sufficient for one batch.
269    pub fn can_run(&self, available: &HashMap<CommodityRef, f64>) -> bool {
270        self.inputs.iter().all(|ing| {
271            available.get(&ing.commodity).copied().unwrap_or(0.0) >= ing.amount
272        })
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Production Quota
278// ---------------------------------------------------------------------------
279
280/// A production target/quota for a building.
281#[derive(Debug, Clone)]
282pub struct ProductionQuota {
283    pub building: BuildingId,
284    /// Target output per tick.
285    pub target_per_tick: f64,
286    pub commodity: CommodityRef,
287    /// Current fulfillment fraction (0.0 – 1.0).
288    pub fulfillment: f64,
289    /// History of fulfillment fractions.
290    pub fulfillment_history: VecDeque<f64>,
291    /// Whether the quota is mandatory (shortfall triggers a supply chain event).
292    pub mandatory: bool,
293}
294
295impl ProductionQuota {
296    pub fn new(building: BuildingId, target: f64, commodity: CommodityRef, mandatory: bool) -> Self {
297        Self {
298            building,
299            target_per_tick: target,
300            commodity,
301            fulfillment: 1.0,
302            fulfillment_history: VecDeque::with_capacity(32),
303            mandatory,
304        }
305    }
306
307    pub fn update_fulfillment(&mut self, actual: f64) {
308        self.fulfillment = if self.target_per_tick > 0.0 {
309            (actual / self.target_per_tick).min(1.0)
310        } else {
311            1.0
312        };
313        self.fulfillment_history.push_back(self.fulfillment);
314        if self.fulfillment_history.len() > 32 { self.fulfillment_history.pop_front(); }
315    }
316
317    pub fn avg_fulfillment(&self, n: usize) -> f64 {
318        let slice: Vec<_> = self.fulfillment_history.iter().rev().take(n).collect();
319        if slice.is_empty() { return 1.0; }
320        slice.iter().copied().sum::<f64>() / slice.len() as f64
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Efficiency Modifiers
326// ---------------------------------------------------------------------------
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum ModifierKind {
330    /// Skilled workforce bonus.
331    SkilledLabor,
332    /// Advanced machinery.
333    Machinery,
334    /// Infrastructure (roads, storage).
335    Infrastructure,
336    /// Negative: weather disruption.
337    WeatherPenalty,
338    /// Negative: shortage of an input commodity.
339    InputShortage,
340    /// Positive: research bonus.
341    ResearchBonus,
342    /// Positive: overseer present.
343    OverseerBonus,
344    /// Negative: corruption / graft.
345    Corruption,
346}
347
348#[derive(Debug, Clone)]
349pub struct EfficiencyModifier {
350    pub kind: ModifierKind,
351    pub magnitude: f64,
352    /// Ticks remaining; 0 = permanent until explicitly removed.
353    pub duration: u64,
354    pub description: String,
355}
356
357impl EfficiencyModifier {
358    pub fn new(kind: ModifierKind, magnitude: f64, duration: u64, description: &str) -> Self {
359        Self { kind, magnitude, duration, description: description.to_string() }
360    }
361
362    pub fn effective_multiplier(&self) -> f64 {
363        match self.kind {
364            ModifierKind::WeatherPenalty | ModifierKind::InputShortage | ModifierKind::Corruption => {
365                1.0 - self.magnitude.abs().min(0.9)
366            }
367            _ => 1.0 + self.magnitude.abs().min(1.0),
368        }
369    }
370}
371
372// ---------------------------------------------------------------------------
373// Supply Chain Event
374// ---------------------------------------------------------------------------
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum SupplyChainEventKind {
378    InputShortage,
379    OutputBacklog,
380    WorkerStrike,
381    EquipmentFailure,
382    RouteInterruption,
383    QuotaShortfall,
384    ReserveDepletion,
385}
386
387#[derive(Debug, Clone)]
388pub struct SupplyChainEvent {
389    pub kind: SupplyChainEventKind,
390    pub building_or_node: u32,
391    pub commodity: Option<CommodityRef>,
392    pub severity: f64,
393    pub tick: u64,
394    pub description: String,
395    pub resolved: bool,
396}
397
398// ---------------------------------------------------------------------------
399// Stockpile
400// ---------------------------------------------------------------------------
401
402/// A multi-commodity stockpile, used by processing buildings to hold inputs and outputs.
403#[derive(Debug, Clone)]
404pub struct Stockpile {
405    pub owner: BuildingId,
406    /// Map of commodity -> current amount.
407    pub contents: HashMap<CommodityRef, f64>,
408    /// Per-commodity capacity limits (None = unlimited).
409    pub capacities: HashMap<CommodityRef, f64>,
410    /// Per-commodity reserved amounts (reserved for a production batch in progress).
411    pub reserved: HashMap<CommodityRef, f64>,
412    /// Total weight/volume capacity (0 = no limit).
413    pub total_capacity: f64,
414    /// Whether to auto-reorder when an input falls below threshold.
415    pub auto_reorder: bool,
416    /// Reorder threshold per commodity (fraction of capacity).
417    pub reorder_threshold: HashMap<CommodityRef, f64>,
418}
419
420impl Stockpile {
421    pub fn new(owner: BuildingId, total_capacity: f64) -> Self {
422        Self {
423            owner,
424            contents: HashMap::new(),
425            capacities: HashMap::new(),
426            reserved: HashMap::new(),
427            total_capacity,
428            auto_reorder: true,
429            reorder_threshold: HashMap::new(),
430        }
431    }
432
433    pub fn amount(&self, commodity: CommodityRef) -> f64 {
434        self.contents.get(&commodity).copied().unwrap_or(0.0)
435    }
436
437    pub fn available(&self, commodity: CommodityRef) -> f64 {
438        let total = self.amount(commodity);
439        let res = self.reserved.get(&commodity).copied().unwrap_or(0.0);
440        (total - res).max(0.0)
441    }
442
443    /// Deposit `amount` of commodity. Returns amount actually accepted.
444    pub fn deposit(&mut self, commodity: CommodityRef, amount: f64) -> f64 {
445        let cap = self.capacities.get(&commodity).copied();
446        let current = self.amount(commodity);
447        let accept = match cap {
448            Some(c) => (c - current).max(0.0).min(amount),
449            None => amount,
450        };
451        *self.contents.entry(commodity).or_insert(0.0) += accept;
452        accept
453    }
454
455    /// Withdraw `amount` of commodity. Returns amount taken.
456    pub fn withdraw(&mut self, commodity: CommodityRef, amount: f64) -> f64 {
457        let current = self.amount(commodity);
458        let taken = current.min(amount);
459        *self.contents.entry(commodity).or_insert(0.0) -= taken;
460        taken
461    }
462
463    /// Reserve commodity for a batch in progress.
464    pub fn reserve(&mut self, commodity: CommodityRef, amount: f64) -> bool {
465        if self.available(commodity) < amount { return false; }
466        *self.reserved.entry(commodity).or_insert(0.0) += amount;
467        true
468    }
469
470    /// Consume reserved commodity (remove from both reserved and contents).
471    pub fn consume_reserved(&mut self, commodity: CommodityRef, amount: f64) {
472        let res = self.reserved.entry(commodity).or_insert(0.0);
473        *res = (*res - amount).max(0.0);
474        let cnt = self.contents.entry(commodity).or_insert(0.0);
475        *cnt = (*cnt - amount).max(0.0);
476    }
477
478    pub fn total_items(&self) -> f64 {
479        self.contents.values().sum()
480    }
481
482    /// Check which commodities are below their reorder threshold.
483    pub fn reorder_needed(&self) -> Vec<CommodityRef> {
484        if !self.auto_reorder { return Vec::new(); }
485        self.reorder_threshold.iter().filter_map(|(&com, &thresh)| {
486            let cap = self.capacities.get(&com).copied().unwrap_or(1000.0);
487            let current = self.amount(com);
488            if current < cap * thresh { Some(com) } else { None }
489        }).collect()
490    }
491}
492
493// ---------------------------------------------------------------------------
494// Production Batch (in-progress)
495// ---------------------------------------------------------------------------
496
497#[derive(Debug, Clone)]
498struct ProductionBatch {
499    recipe_id: RecipeId,
500    started_tick: u64,
501    complete_tick: u64,
502    worker_count: u32,
503    energy_used: f64,
504    efficiency_at_start: f64,
505}
506
507// ---------------------------------------------------------------------------
508// Processing Building
509// ---------------------------------------------------------------------------
510
511/// A building that transforms input commodities into output commodities.
512#[derive(Debug, Clone)]
513pub struct ProcessingBuilding {
514    pub id: BuildingId,
515    pub name: String,
516    /// Available recipes.
517    pub recipes: Vec<RecipeId>,
518    /// Currently running batch, if any.
519    active_batch: Option<ProductionBatch>,
520    /// Recipe queue: which recipe to run next.
521    pub recipe_queue: VecDeque<RecipeId>,
522    /// Input/output stockpile.
523    pub stockpile: Stockpile,
524    /// Workers assigned to this building.
525    pub workers: Vec<WorkerId>,
526    /// Max worker slots.
527    pub worker_slots: u32,
528    /// Efficiency modifiers currently in effect.
529    pub modifiers: Vec<EfficiencyModifier>,
530    /// Production quota.
531    pub quota: Option<ProductionQuota>,
532    /// Throughput per tick (units produced).
533    pub throughput_history: VecDeque<f64>,
534    /// Total lifetime output (per commodity).
535    pub lifetime_output: HashMap<CommodityRef, f64>,
536    /// Whether this building is operational.
537    pub operational: bool,
538    /// Ticks this building has been running.
539    pub age_ticks: u64,
540    /// Total batches completed.
541    pub batches_completed: u64,
542}
543
544impl ProcessingBuilding {
545    pub fn new(id: BuildingId, name: &str, worker_slots: u32, stockpile_capacity: f64) -> Self {
546        Self {
547            id,
548            name: name.to_string(),
549            recipes: Vec::new(),
550            active_batch: None,
551            recipe_queue: VecDeque::new(),
552            stockpile: Stockpile::new(id, stockpile_capacity),
553            workers: Vec::new(),
554            worker_slots,
555            modifiers: Vec::new(),
556            quota: None,
557            throughput_history: VecDeque::with_capacity(64),
558            lifetime_output: HashMap::new(),
559            operational: true,
560            age_ticks: 0,
561            batches_completed: 0,
562        }
563    }
564
565    pub fn add_recipe(&mut self, recipe_id: RecipeId) {
566        if !self.recipes.contains(&recipe_id) {
567            self.recipes.push(recipe_id);
568        }
569    }
570
571    pub fn assign_worker(&mut self, worker: WorkerId) -> bool {
572        if self.workers.len() as u32 >= self.worker_slots { return false; }
573        if self.workers.contains(&worker) { return false; }
574        self.workers.push(worker);
575        true
576    }
577
578    pub fn remove_worker(&mut self, worker: WorkerId) -> bool {
579        if let Some(pos) = self.workers.iter().position(|&w| w == worker) {
580            self.workers.remove(pos);
581            true
582        } else {
583            false
584        }
585    }
586
587    pub fn worker_count(&self) -> u32 {
588        self.workers.len() as u32
589    }
590
591    /// Composite efficiency from all active modifiers.
592    pub fn composite_efficiency(&self) -> f64 {
593        let base = 1.0f64;
594        self.modifiers.iter().fold(base, |acc, m| acc * m.effective_multiplier()).clamp(0.05, 3.0)
595    }
596
597    pub fn add_modifier(&mut self, modifier: EfficiencyModifier) {
598        self.modifiers.push(modifier);
599    }
600
601    pub fn remove_modifier(&mut self, kind: ModifierKind) {
602        self.modifiers.retain(|m| m.kind != kind);
603    }
604
605    /// Tick modifiers: decrement durations and remove expired ones.
606    fn tick_modifiers(&mut self) {
607        for m in self.modifiers.iter_mut() {
608            if m.duration > 0 { m.duration -= 1; }
609        }
610        self.modifiers.retain(|m| m.duration != 1); // keep permanent (0) and still-active
611    }
612
613    /// Attempt to start a new production batch using a recipe.
614    /// Returns true if batch was started.
615    fn start_batch(&mut self, recipe: &Recipe, current_tick: u64) -> bool {
616        if self.active_batch.is_some() { return false; }
617        if self.worker_count() < recipe.workers_required { return false; }
618        // Check and reserve inputs
619        let available: HashMap<CommodityRef, f64> = self.stockpile.contents.clone();
620        if !recipe.can_run(&available) { return false; }
621        // Reserve inputs
622        for ing in &recipe.inputs {
623            if !self.stockpile.reserve(ing.commodity, ing.amount) { return false; }
624        }
625        let eff = self.composite_efficiency();
626        let actual_ticks = (recipe.base_ticks as f64 / eff).round().max(1.0) as u64;
627        self.active_batch = Some(ProductionBatch {
628            recipe_id: recipe.id,
629            started_tick: current_tick,
630            complete_tick: current_tick + actual_ticks,
631            worker_count: recipe.workers_required,
632            energy_used: recipe.energy_required,
633            efficiency_at_start: eff,
634        });
635        true
636    }
637
638    /// Complete a batch: consume reserved inputs and produce outputs.
639    fn complete_batch(&mut self, recipe: &Recipe) -> HashMap<CommodityRef, f64> {
640        let batch = match self.active_batch.take() {
641            Some(b) => b,
642            None => return HashMap::new(),
643        };
644        // Consume reserved inputs
645        for ing in &recipe.inputs {
646            self.stockpile.consume_reserved(ing.commodity, ing.amount);
647        }
648        // Produce outputs scaled by efficiency
649        let mut produced: HashMap<CommodityRef, f64> = HashMap::new();
650        for ing in &recipe.outputs {
651            let amount = ing.amount * batch.efficiency_at_start;
652            self.stockpile.deposit(ing.commodity, amount);
653            *produced.entry(ing.commodity).or_insert(0.0) += amount;
654            *self.lifetime_output.entry(ing.commodity).or_insert(0.0) += amount;
655        }
656        self.batches_completed += 1;
657        produced
658    }
659
660    /// Full tick: advance modifier timers, check batch completion, start new batch.
661    pub fn tick(
662        &mut self,
663        recipes: &HashMap<RecipeId, Recipe>,
664        current_tick: u64,
665    ) -> (HashMap<CommodityRef, f64>, Vec<SupplyChainEvent>) {
666        self.age_ticks += 1;
667        self.tick_modifiers();
668
669        if !self.operational {
670            self.throughput_history.push_back(0.0);
671            if self.throughput_history.len() > 64 { self.throughput_history.pop_front(); }
672            return (HashMap::new(), Vec::new());
673        }
674
675        let mut produced: HashMap<CommodityRef, f64> = HashMap::new();
676        let mut events: Vec<SupplyChainEvent> = Vec::new();
677
678        // Check batch completion
679        let batch_complete = self.active_batch.as_ref()
680            .map(|b| current_tick >= b.complete_tick)
681            .unwrap_or(false);
682
683        if batch_complete {
684            if let Some(batch) = &self.active_batch {
685                let rid = batch.recipe_id;
686                if let Some(recipe) = recipes.get(&rid) {
687                    let batch_recipe = recipe.clone();
688                    produced = self.complete_batch(&batch_recipe);
689                }
690            }
691        }
692
693        // Try to start next batch
694        if self.active_batch.is_none() {
695            let next_recipe_id = self.recipe_queue.front().copied()
696                .or_else(|| self.recipes.first().copied());
697
698            if let Some(rid) = next_recipe_id {
699                if let Some(recipe) = recipes.get(&rid) {
700                    let recipe_clone = recipe.clone();
701                    if !self.start_batch(&recipe_clone, current_tick) {
702                        // Could not start: check why
703                        let available: HashMap<CommodityRef, f64> = self.stockpile.contents.clone();
704                        if !recipe_clone.can_run(&available) {
705                            for ing in &recipe_clone.inputs {
706                                let have = available.get(&ing.commodity).copied().unwrap_or(0.0);
707                                if have < ing.amount {
708                                    events.push(SupplyChainEvent {
709                                        kind: SupplyChainEventKind::InputShortage,
710                                        building_or_node: self.id.0,
711                                        commodity: Some(ing.commodity),
712                                        severity: 1.0 - have / ing.amount,
713                                        tick: current_tick,
714                                        description: format!(
715                                            "Building {:?}: needs {:.1} of {:?}, has {:.1}",
716                                            self.id, ing.amount, ing.commodity, have
717                                        ),
718                                        resolved: false,
719                                    });
720                                }
721                            }
722                        }
723                        if self.worker_count() < recipe_clone.workers_required {
724                            events.push(SupplyChainEvent {
725                                kind: SupplyChainEventKind::WorkerStrike,
726                                building_or_node: self.id.0,
727                                commodity: None,
728                                severity: 1.0 - self.worker_count() as f64 / recipe_clone.workers_required as f64,
729                                tick: current_tick,
730                                description: format!(
731                                    "Building {:?}: needs {} workers, has {}",
732                                    self.id, recipe_clone.workers_required, self.worker_count()
733                                ),
734                                resolved: false,
735                            });
736                        }
737                    } else if !self.recipe_queue.is_empty() {
738                        self.recipe_queue.pop_front();
739                    }
740                }
741            }
742        }
743
744        // Check stockpile backlog
745        if self.stockpile.total_capacity > 0.0
746            && self.stockpile.total_items() > self.stockpile.total_capacity * 0.95
747        {
748            events.push(SupplyChainEvent {
749                kind: SupplyChainEventKind::OutputBacklog,
750                building_or_node: self.id.0,
751                commodity: None,
752                severity: 0.5,
753                tick: current_tick,
754                description: format!("Building {:?}: stockpile near capacity", self.id),
755                resolved: false,
756            });
757        }
758
759        // Update quota
760        let total_produced: f64 = produced.values().sum();
761        if let Some(ref mut quota) = self.quota {
762            quota.update_fulfillment(total_produced);
763            if quota.mandatory && quota.fulfillment < 0.8 {
764                events.push(SupplyChainEvent {
765                    kind: SupplyChainEventKind::QuotaShortfall,
766                    building_or_node: self.id.0,
767                    commodity: Some(quota.commodity),
768                    severity: 1.0 - quota.fulfillment,
769                    tick: current_tick,
770                    description: format!(
771                        "Building {:?}: quota shortfall {:.0}%",
772                        self.id, quota.fulfillment * 100.0
773                    ),
774                    resolved: false,
775                });
776            }
777        }
778
779        self.throughput_history.push_back(total_produced);
780        if self.throughput_history.len() > 64 { self.throughput_history.pop_front(); }
781
782        (produced, events)
783    }
784
785    pub fn avg_throughput(&self, n: usize) -> f64 {
786        let slice: Vec<_> = self.throughput_history.iter().rev().take(n).collect();
787        if slice.is_empty() { return 0.0; }
788        slice.iter().copied().sum::<f64>() / slice.len() as f64
789    }
790
791    /// Halt the building (equipment failure, strike, etc.).
792    pub fn halt(&mut self, reason: &str, tick: u64) -> SupplyChainEvent {
793        self.operational = false;
794        // Release any reserved stock
795        let reserved: Vec<(CommodityRef, f64)> = self.stockpile.reserved
796            .iter().map(|(&k, &v)| (k, v)).collect();
797        for (com, _) in reserved {
798            self.stockpile.reserved.insert(com, 0.0);
799        }
800        self.active_batch = None;
801        SupplyChainEvent {
802            kind: SupplyChainEventKind::EquipmentFailure,
803            building_or_node: self.id.0,
804            commodity: None,
805            severity: 1.0,
806            tick,
807            description: reason.to_string(),
808            resolved: false,
809        }
810    }
811
812    /// Resume halted building.
813    pub fn resume(&mut self) {
814        self.operational = true;
815    }
816}
817
818// ---------------------------------------------------------------------------
819// Worker Registry
820// ---------------------------------------------------------------------------
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823pub enum WorkerStatus {
824    Idle,
825    Assigned,
826    Striking,
827    Injured,
828}
829
830#[derive(Debug, Clone)]
831pub struct Worker {
832    pub id: WorkerId,
833    pub skill_level: f64,
834    pub status: WorkerStatus,
835    pub assigned_building: Option<BuildingId>,
836    pub assigned_node: Option<NodeId>,
837    pub fatigue: f64,
838    pub lifetime_ticks_worked: u64,
839}
840
841impl Worker {
842    pub fn new(id: WorkerId, skill_level: f64) -> Self {
843        Self {
844            id,
845            skill_level,
846            status: WorkerStatus::Idle,
847            assigned_building: None,
848            assigned_node: None,
849            fatigue: 0.0,
850            lifetime_ticks_worked: 0,
851        }
852    }
853
854    /// Efficiency contribution of this worker based on skill and fatigue.
855    pub fn effective_skill(&self) -> f64 {
856        self.skill_level * (1.0 - self.fatigue * 0.5)
857    }
858
859    pub fn tick(&mut self) {
860        if self.status == WorkerStatus::Assigned {
861            self.fatigue = (self.fatigue + 0.002).min(1.0);
862            self.lifetime_ticks_worked += 1;
863        } else {
864            // Rest
865            self.fatigue = (self.fatigue - 0.01).max(0.0);
866        }
867    }
868}
869
870// ---------------------------------------------------------------------------
871// Production Report
872// ---------------------------------------------------------------------------
873
874/// Summary of one production tick.
875#[derive(Debug, Clone)]
876pub struct ProductionReport {
877    pub tick: u64,
878    /// Commodity -> total units produced this tick.
879    pub total_produced: HashMap<CommodityRef, f64>,
880    /// Commodity -> total units extracted from nodes.
881    pub total_extracted: HashMap<CommodityRef, f64>,
882    /// All supply chain events raised this tick.
883    pub events: Vec<SupplyChainEvent>,
884    /// Number of buildings operational.
885    pub buildings_operational: u32,
886    /// Number of nodes active.
887    pub nodes_active: u32,
888    /// Total workers assigned.
889    pub workers_assigned: u32,
890}
891
892// ---------------------------------------------------------------------------
893// ProductionManager — top-level coordinator
894// ---------------------------------------------------------------------------
895
896/// Manages all resource nodes, processing buildings, workers, recipes,
897/// and coordinates supply chain logic.
898pub struct ProductionManager {
899    next_node_id: u32,
900    next_building_id: u32,
901    next_worker_id: u32,
902    next_recipe_id: u32,
903    pub current_tick: u64,
904
905    pub nodes: HashMap<NodeId, ResourceNode>,
906    pub buildings: HashMap<BuildingId, ProcessingBuilding>,
907    pub workers: HashMap<WorkerId, Worker>,
908    pub recipes: HashMap<RecipeId, Recipe>,
909
910    /// Accumulated supply chain events for this manager's lifetime.
911    pub supply_chain_events: Vec<SupplyChainEvent>,
912
913    /// Simple xorshift64 RNG for yield variance.
914    rng_state: u64,
915
916    /// Global production history (last 128 ticks).
917    pub production_history: VecDeque<ProductionReport>,
918}
919
920impl ProductionManager {
921    pub fn new() -> Self {
922        Self {
923            next_node_id: 1,
924            next_building_id: 1,
925            next_worker_id: 1,
926            next_recipe_id: 1,
927            current_tick: 0,
928            nodes: HashMap::new(),
929            buildings: HashMap::new(),
930            workers: HashMap::new(),
931            recipes: HashMap::new(),
932            supply_chain_events: Vec::new(),
933            rng_state: 0x1234_ABCD_5678_EF00,
934            production_history: VecDeque::with_capacity(128),
935        }
936    }
937
938    fn next_rand(&mut self) -> f64 {
939        let mut x = self.rng_state;
940        x ^= x << 13;
941        x ^= x >> 7;
942        x ^= x << 17;
943        self.rng_state = x;
944        (x as f64) / (u64::MAX as f64)
945    }
946
947    // -----------------------------------------------------------------------
948    // Registration
949    // -----------------------------------------------------------------------
950
951    pub fn add_node(
952        &mut self,
953        name: &str,
954        kind: NodeKind,
955        output_commodity: CommodityRef,
956        base_yield: f64,
957        reserves: Option<f64>,
958        worker_capacity: u32,
959        stockpile_capacity: f64,
960    ) -> NodeId {
961        let id = NodeId(self.next_node_id);
962        self.next_node_id += 1;
963        self.nodes.insert(id, ResourceNode::new(id, name, kind, output_commodity, base_yield, reserves, worker_capacity, stockpile_capacity));
964        id
965    }
966
967    pub fn add_building(
968        &mut self,
969        name: &str,
970        worker_slots: u32,
971        stockpile_capacity: f64,
972    ) -> BuildingId {
973        let id = BuildingId(self.next_building_id);
974        self.next_building_id += 1;
975        self.buildings.insert(id, ProcessingBuilding::new(id, name, worker_slots, stockpile_capacity));
976        id
977    }
978
979    pub fn add_recipe(
980        &mut self,
981        name: &str,
982        inputs: Vec<Ingredient>,
983        outputs: Vec<Ingredient>,
984        base_ticks: u64,
985        workers_required: u32,
986        energy_required: f64,
987    ) -> RecipeId {
988        let id = RecipeId(self.next_recipe_id);
989        self.next_recipe_id += 1;
990        self.recipes.insert(id, Recipe { id, name: name.to_string(), inputs, outputs, base_ticks, workers_required, energy_required });
991        id
992    }
993
994    pub fn add_worker(&mut self, skill_level: f64) -> WorkerId {
995        let id = WorkerId(self.next_worker_id);
996        self.next_worker_id += 1;
997        self.workers.insert(id, Worker::new(id, skill_level));
998        id
999    }
1000
1001    // -----------------------------------------------------------------------
1002    // Assignment
1003    // -----------------------------------------------------------------------
1004
1005    pub fn assign_worker_to_building(&mut self, worker: WorkerId, building: BuildingId) -> bool {
1006        if let Some(b) = self.buildings.get_mut(&building) {
1007            if b.assign_worker(worker) {
1008                if let Some(w) = self.workers.get_mut(&worker) {
1009                    w.status = WorkerStatus::Assigned;
1010                    w.assigned_building = Some(building);
1011                }
1012                return true;
1013            }
1014        }
1015        false
1016    }
1017
1018    pub fn assign_worker_to_node(&mut self, worker: WorkerId, node: NodeId) -> bool {
1019        if let Some(n) = self.nodes.get_mut(&node) {
1020            if n.assign_workers(1) == 1 {
1021                if let Some(w) = self.workers.get_mut(&worker) {
1022                    w.status = WorkerStatus::Assigned;
1023                    w.assigned_node = Some(node);
1024                }
1025                return true;
1026            }
1027        }
1028        false
1029    }
1030
1031    pub fn assign_recipe_to_building(&mut self, building: BuildingId, recipe: RecipeId) {
1032        if let Some(b) = self.buildings.get_mut(&building) {
1033            b.add_recipe(recipe);
1034        }
1035    }
1036
1037    pub fn queue_recipe(&mut self, building: BuildingId, recipe: RecipeId) {
1038        if let Some(b) = self.buildings.get_mut(&building) {
1039            b.recipe_queue.push_back(recipe);
1040        }
1041    }
1042
1043    // -----------------------------------------------------------------------
1044    // Supply Transfer
1045    // -----------------------------------------------------------------------
1046
1047    /// Transfer output from a node's stockpile to a building's input stockpile.
1048    pub fn transfer_node_to_building(
1049        &mut self,
1050        node: NodeId,
1051        building: BuildingId,
1052        commodity: CommodityRef,
1053        max_amount: f64,
1054    ) -> f64 {
1055        let taken = match self.nodes.get_mut(&node) {
1056            Some(n) => n.withdraw(max_amount),
1057            None => return 0.0,
1058        };
1059        if taken > 0.0 {
1060            if let Some(b) = self.buildings.get_mut(&building) {
1061                let accepted = b.stockpile.deposit(commodity, taken);
1062                let _ = accepted;
1063            }
1064        }
1065        taken
1066    }
1067
1068    /// Withdraw output from a building's stockpile (for downstream delivery).
1069    pub fn withdraw_from_building(
1070        &mut self,
1071        building: BuildingId,
1072        commodity: CommodityRef,
1073        amount: f64,
1074    ) -> f64 {
1075        self.buildings.get_mut(&building)
1076            .map(|b| b.stockpile.withdraw(commodity, amount))
1077            .unwrap_or(0.0)
1078    }
1079
1080    // -----------------------------------------------------------------------
1081    // Disruption
1082    // -----------------------------------------------------------------------
1083
1084    pub fn disrupt_node(&mut self, node: NodeId, severity: f64) {
1085        if let Some(n) = self.nodes.get_mut(&node) {
1086            n.apply_disruption(severity);
1087        }
1088    }
1089
1090    pub fn disrupt_building(&mut self, building: BuildingId, reason: &str) {
1091        let tick = self.current_tick;
1092        if let Some(b) = self.buildings.get_mut(&building) {
1093            let evt = b.halt(reason, tick);
1094            self.supply_chain_events.push(evt);
1095        }
1096    }
1097
1098    pub fn repair_building(&mut self, building: BuildingId) {
1099        if let Some(b) = self.buildings.get_mut(&building) {
1100            b.resume();
1101        }
1102    }
1103
1104    pub fn maintain_node(&mut self, node: NodeId) {
1105        if let Some(n) = self.nodes.get_mut(&node) {
1106            n.perform_maintenance();
1107        }
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // Season Update
1112    // -----------------------------------------------------------------------
1113
1114    /// Set season modifier for all nodes of a given kind.
1115    pub fn set_season(&mut self, kind: NodeKind, modifier: f64) {
1116        for n in self.nodes.values_mut() {
1117            if n.kind == kind {
1118                n.season_modifier = modifier.clamp(0.0, 2.0);
1119            }
1120        }
1121    }
1122
1123    // -----------------------------------------------------------------------
1124    // Main Tick
1125    // -----------------------------------------------------------------------
1126
1127    /// Advance production by one tick.
1128    ///
1129    /// - Ticks all workers (fatigue).
1130    /// - Ticks all resource nodes (yield, depletion, disruption recovery).
1131    /// - Ticks all processing buildings (batch progress, input matching).
1132    /// - Accumulates supply chain events.
1133    /// - Produces a ProductionReport.
1134    pub fn tick(&mut self) -> ProductionReport {
1135        self.current_tick += 1;
1136        let tick = self.current_tick;
1137
1138        // --- Workers ---
1139        for w in self.workers.values_mut() {
1140            w.tick();
1141        }
1142
1143        let mut total_produced: HashMap<CommodityRef, f64> = HashMap::new();
1144        let mut total_extracted: HashMap<CommodityRef, f64> = HashMap::new();
1145        let mut all_events: Vec<SupplyChainEvent> = Vec::new();
1146
1147        // --- Resource Nodes ---
1148        let node_ids: Vec<NodeId> = self.nodes.keys().copied().collect();
1149        let mut nodes_active = 0u32;
1150        for &nid in &node_ids {
1151            let rng_v = self.next_rand();
1152            let node = self.nodes.get_mut(&nid).unwrap();
1153            if node.active { nodes_active += 1; }
1154            let output = node.tick_output(rng_v);
1155            *total_extracted.entry(node.output_commodity).or_insert(0.0) += output;
1156            node.recover_disruption(0.02);
1157            // Emit reserve depletion event if nearly depleted
1158            if let Some(frac) = node.depletion_fraction() {
1159                if frac > 0.90 && output > 0.0 {
1160                    all_events.push(SupplyChainEvent {
1161                        kind: SupplyChainEventKind::ReserveDepletion,
1162                        building_or_node: nid.0,
1163                        commodity: Some(node.output_commodity),
1164                        severity: frac,
1165                        tick,
1166                        description: format!("Node {:?} reserves at {:.1}% depletion", nid, frac * 100.0),
1167                        resolved: false,
1168                    });
1169                }
1170            }
1171        }
1172
1173        // --- Processing Buildings ---
1174        let building_ids: Vec<BuildingId> = self.buildings.keys().copied().collect();
1175        let mut buildings_operational = 0u32;
1176        for &bid in &building_ids {
1177            // Borrow recipes separately
1178            let recipes_snapshot: HashMap<RecipeId, Recipe> = self.recipes.clone();
1179            let building = self.buildings.get_mut(&bid).unwrap();
1180            if building.operational { buildings_operational += 1; }
1181            let (produced, events) = building.tick(&recipes_snapshot, tick);
1182            for (com, amt) in produced {
1183                *total_produced.entry(com).or_insert(0.0) += amt;
1184            }
1185            all_events.extend(events);
1186        }
1187
1188        // Count workers assigned
1189        let workers_assigned = self.workers.values()
1190            .filter(|w| w.status == WorkerStatus::Assigned)
1191            .count() as u32;
1192
1193        // Accumulate global events
1194        self.supply_chain_events.extend(all_events.iter().cloned());
1195        // Keep last 512 events
1196        if self.supply_chain_events.len() > 512 {
1197            let drain = self.supply_chain_events.len() - 512;
1198            self.supply_chain_events.drain(0..drain);
1199        }
1200
1201        let report = ProductionReport {
1202            tick,
1203            total_produced,
1204            total_extracted,
1205            events: all_events,
1206            buildings_operational,
1207            nodes_active,
1208            workers_assigned,
1209        };
1210
1211        self.production_history.push_back(report.clone());
1212        if self.production_history.len() > 128 { self.production_history.pop_front(); }
1213        report
1214    }
1215
1216    // -----------------------------------------------------------------------
1217    // Query Helpers
1218    // -----------------------------------------------------------------------
1219
1220    /// Total current stockpile across all nodes for a commodity.
1221    pub fn node_stockpile(&self, commodity: CommodityRef) -> f64 {
1222        self.nodes.values()
1223            .filter(|n| n.output_commodity == commodity)
1224            .map(|n| n.stockpile)
1225            .sum()
1226    }
1227
1228    /// Total current stockpile in buildings for a commodity (output side).
1229    pub fn building_stockpile(&self, commodity: CommodityRef) -> f64 {
1230        self.buildings.values()
1231            .map(|b| b.stockpile.amount(commodity))
1232            .sum()
1233    }
1234
1235    /// All unresolved supply chain events.
1236    pub fn unresolved_events(&self) -> Vec<&SupplyChainEvent> {
1237        self.supply_chain_events.iter().filter(|e| !e.resolved).collect()
1238    }
1239
1240    /// Resolve an event by index.
1241    pub fn resolve_event(&mut self, index: usize) {
1242        if let Some(e) = self.supply_chain_events.get_mut(index) {
1243            e.resolved = true;
1244        }
1245    }
1246
1247    /// Summary of average throughput across last n ticks per commodity.
1248    pub fn avg_throughput(&self, commodity: CommodityRef, n: usize) -> f64 {
1249        let samples: Vec<f64> = self.production_history.iter().rev().take(n)
1250            .map(|r| r.total_produced.get(&commodity).copied().unwrap_or(0.0))
1251            .collect();
1252        if samples.is_empty() { return 0.0; }
1253        samples.iter().sum::<f64>() / samples.len() as f64
1254    }
1255
1256    /// Find nodes near depletion.
1257    pub fn near_depletion_nodes(&self, threshold: f64) -> Vec<NodeId> {
1258        self.nodes.values()
1259            .filter(|n| n.depletion_fraction().map(|f| f > threshold).unwrap_or(false))
1260            .map(|n| n.id)
1261            .collect()
1262    }
1263
1264    /// Idle workers.
1265    pub fn idle_workers(&self) -> Vec<WorkerId> {
1266        self.workers.values()
1267            .filter(|w| w.status == WorkerStatus::Idle)
1268            .map(|w| w.id)
1269            .collect()
1270    }
1271
1272    /// Buildings with open worker slots.
1273    pub fn understaffed_buildings(&self) -> Vec<BuildingId> {
1274        self.buildings.values()
1275            .filter(|b| b.worker_count() < b.worker_slots)
1276            .map(|b| b.id)
1277            .collect()
1278    }
1279
1280    /// Auto-assign idle workers to understaffed buildings (greedy).
1281    pub fn auto_assign_workers(&mut self) {
1282        let idle: Vec<WorkerId> = self.idle_workers();
1283        let understaffed: Vec<BuildingId> = self.understaffed_buildings();
1284        let mut worker_iter = idle.into_iter();
1285        for bid in understaffed {
1286            let slots_needed = {
1287                let b = &self.buildings[&bid];
1288                b.worker_slots - b.worker_count()
1289            };
1290            for _ in 0..slots_needed {
1291                match worker_iter.next() {
1292                    Some(wid) => { self.assign_worker_to_building(wid, bid); }
1293                    None => return,
1294                }
1295            }
1296        }
1297    }
1298}
1299
1300impl Default for ProductionManager {
1301    fn default() -> Self {
1302        Self::new()
1303    }
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Tests
1308// ---------------------------------------------------------------------------
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313
1314    #[test]
1315    fn test_node_extraction() {
1316        let mut pm = ProductionManager::new();
1317        let iron = CommodityRef(1);
1318        let nid = pm.add_node("Iron Mine", NodeKind::Mine, iron, 10.0, Some(100_000.0), 10, 5000.0);
1319        // Assign 5 workers
1320        for _ in 0..5 {
1321            let wid = pm.add_worker(1.0);
1322            pm.assign_worker_to_node(wid, nid);
1323        }
1324        let report = pm.tick();
1325        let extracted = report.total_extracted.get(&iron).copied().unwrap_or(0.0);
1326        assert!(extracted > 0.0, "mine should produce output: {}", extracted);
1327    }
1328
1329    #[test]
1330    fn test_building_recipe_completion() {
1331        let mut pm = ProductionManager::new();
1332        let ore = CommodityRef(1);
1333        let steel = CommodityRef(2);
1334        let bid = pm.add_building("Smelter", 4, 10000.0);
1335        let rid = pm.add_recipe(
1336            "Smelt Iron",
1337            vec![Ingredient { commodity: ore, amount: 10.0 }],
1338            vec![Ingredient { commodity: steel, amount: 5.0 }],
1339            3,
1340            2,
1341            0.0,
1342        );
1343        pm.assign_recipe_to_building(bid, rid);
1344        // Add workers
1345        for _ in 0..2 {
1346            let wid = pm.add_worker(1.0);
1347            pm.assign_worker_to_building(wid, bid);
1348        }
1349        // Pre-load ore into stockpile
1350        pm.buildings.get_mut(&bid).unwrap().stockpile.deposit(ore, 50.0);
1351        // Tick enough times for the batch to complete (base_ticks=3)
1352        for _ in 0..5 {
1353            pm.tick();
1354        }
1355        let steel_in_stockpile = pm.buildings[&bid].stockpile.amount(steel);
1356        assert!(steel_in_stockpile > 0.0, "smelter should have produced steel: {}", steel_in_stockpile);
1357    }
1358
1359    #[test]
1360    fn test_supply_chain_input_shortage() {
1361        let mut pm = ProductionManager::new();
1362        let ore = CommodityRef(1);
1363        let steel = CommodityRef(2);
1364        let bid = pm.add_building("Smelter", 4, 10000.0);
1365        let rid = pm.add_recipe(
1366            "Smelt Iron",
1367            vec![Ingredient { commodity: ore, amount: 100.0 }],
1368            vec![Ingredient { commodity: steel, amount: 50.0 }],
1369            2,
1370            1,
1371            0.0,
1372        );
1373        pm.assign_recipe_to_building(bid, rid);
1374        let wid = pm.add_worker(1.0);
1375        pm.assign_worker_to_building(wid, bid);
1376        // No ore in stockpile -> shortage event
1377        let report = pm.tick();
1378        let has_shortage = report.events.iter().any(|e| e.kind == SupplyChainEventKind::InputShortage);
1379        assert!(has_shortage, "should report input shortage");
1380    }
1381
1382    #[test]
1383    fn test_depletion_event() {
1384        let mut pm = ProductionManager::new();
1385        let coal = CommodityRef(3);
1386        let nid = pm.add_node("Coal Mine", NodeKind::Mine, coal, 1000.0, Some(10.0), 5, 50000.0);
1387        for _ in 0..5 {
1388            let wid = pm.add_worker(1.0);
1389            pm.assign_worker_to_node(wid, nid);
1390        }
1391        // Manually deplete reserves
1392        pm.nodes.get_mut(&nid).unwrap().reserves = Some(0.5);
1393        let report = pm.tick();
1394        let depleted = report.events.iter().any(|e| e.kind == SupplyChainEventKind::ReserveDepletion);
1395        assert!(depleted, "near-empty reserves should trigger depletion event");
1396    }
1397
1398    #[test]
1399    fn test_maintenance_restores_efficiency() {
1400        let mut pm = ProductionManager::new();
1401        let gold = CommodityRef(4);
1402        let nid = pm.add_node("Gold Mine", NodeKind::Mine, gold, 5.0, Some(1_000_000.0), 3, 1000.0);
1403        let node = pm.nodes.get_mut(&nid).unwrap();
1404        node.efficiency = 0.4;
1405        node.perform_maintenance();
1406        assert!(node.efficiency > 0.6, "maintenance should restore efficiency");
1407    }
1408
1409    #[test]
1410    fn test_worker_fatigue() {
1411        let mut pm = ProductionManager::new();
1412        let wood = CommodityRef(5);
1413        let nid = pm.add_node("Forest", NodeKind::Forest, wood, 8.0, None, 10, 5000.0);
1414        let wid = pm.add_worker(1.0);
1415        pm.assign_worker_to_node(wid, nid);
1416        for _ in 0..100 { pm.tick(); }
1417        let worker = &pm.workers[&wid];
1418        assert!(worker.fatigue > 0.1, "worker should accumulate fatigue: {}", worker.fatigue);
1419    }
1420
1421    #[test]
1422    fn test_auto_assign_workers() {
1423        let mut pm = ProductionManager::new();
1424        let bid = pm.add_building("Workshop", 3, 1000.0);
1425        for _ in 0..3 { pm.add_worker(1.0); }
1426        pm.auto_assign_workers();
1427        let b = &pm.buildings[&bid];
1428        assert_eq!(b.worker_count(), 3);
1429    }
1430
1431    #[test]
1432    fn test_season_modifier() {
1433        let mut pm = ProductionManager::new();
1434        let grain = CommodityRef(6);
1435        let nid = pm.add_node("Farm", NodeKind::Farm, grain, 20.0, None, 5, 2000.0);
1436        for _ in 0..5 {
1437            let wid = pm.add_worker(1.0);
1438            pm.assign_worker_to_node(wid, nid);
1439        }
1440        pm.set_season(NodeKind::Farm, 0.0); // winter
1441        let report = pm.tick();
1442        let extracted = report.total_extracted.get(&grain).copied().unwrap_or(0.0);
1443        assert_eq!(extracted, 0.0, "winter should halt farm output");
1444    }
1445
1446    #[test]
1447    fn test_node_to_building_transfer() {
1448        let mut pm = ProductionManager::new();
1449        let ore = CommodityRef(1);
1450        let nid = pm.add_node("Mine", NodeKind::Mine, ore, 10.0, Some(10_000.0), 5, 1000.0);
1451        let bid = pm.add_building("Smelter", 2, 5000.0);
1452        // Put ore directly into node stockpile
1453        pm.nodes.get_mut(&nid).unwrap().stockpile = 200.0;
1454        let transferred = pm.transfer_node_to_building(nid, bid, ore, 100.0);
1455        assert!((transferred - 100.0).abs() < 1e-9);
1456        assert!((pm.buildings[&bid].stockpile.amount(ore) - 100.0).abs() < 1e-9);
1457    }
1458}