1use std::collections::{HashMap, VecDeque};
9
10#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct CommodityRef(pub u32);
29
30#[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 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 pub fn depletion_rate(&self) -> f64 {
61 match self {
62 NodeKind::Mine => 0.001,
63 NodeKind::Farm => 0.0, 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 pub fn is_renewable(&self) -> bool {
74 matches!(self, NodeKind::Farm | NodeKind::HerbGarden)
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct ResourceNode {
81 pub id: NodeId,
82 pub name: String,
83 pub kind: NodeKind,
84 pub output_commodity: CommodityRef,
86 pub base_yield_per_worker: f64,
88 pub reserves: Option<f64>,
90 pub max_reserves: Option<f64>,
92 pub workers_assigned: u32,
94 pub worker_capacity: u32,
96 pub stockpile: f64,
98 pub stockpile_capacity: f64,
100 pub efficiency: f64,
102 pub degradation_rate: f64,
104 pub maintenance_due_in: u64,
106 pub disruption: f64,
108 pub season_modifier: f64,
110 pub output_history: VecDeque<f64>,
112 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 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 pub fn remove_workers(&mut self, count: u32) {
160 self.workers_assigned = self.workers_assigned.saturating_sub(count);
161 }
162
163 pub fn compute_output(&self, rng_variance: f64) -> f64 {
165 if !self.active || self.workers_assigned == 0 { return 0.0; }
166 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 pub fn tick_output(&mut self, rng_variance: f64) -> f64 {
183 let gross = self.compute_output(rng_variance);
184 if let Some(r) = self.reserves.as_mut() {
186 let consumed = gross * self.kind.depletion_rate() * 500.0; *r = (*r - consumed).max(0.0);
188 }
189 let actual = (self.stockpile + gross).min(self.stockpile_capacity);
191 let produced = actual - self.stockpile;
192 self.stockpile = actual;
193 self.output_history.push_back(produced);
195 if self.output_history.len() > 64 { self.output_history.pop_front(); }
196 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 pub fn withdraw(&mut self, amount: f64) -> f64 {
204 let taken = amount.min(self.stockpile);
205 self.stockpile -= taken;
206 taken
207 }
208
209 pub fn perform_maintenance(&mut self) {
211 self.efficiency = (self.efficiency + 0.3).min(1.2); self.maintenance_due_in = 200;
213 }
214
215 pub fn apply_disruption(&mut self, severity: f64) {
217 self.disruption = (self.disruption + severity).min(1.0);
218 }
219
220 pub fn recover_disruption(&mut self, rate: f64) {
222 self.disruption = (self.disruption - rate).max(0.0);
223 }
224
225 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 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#[derive(Debug, Clone)]
247pub struct Ingredient {
248 pub commodity: CommodityRef,
249 pub amount: f64,
250}
251
252#[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 pub base_ticks: u64,
261 pub workers_required: u32,
263 pub energy_required: f64,
265}
266
267impl Recipe {
268 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#[derive(Debug, Clone)]
282pub struct ProductionQuota {
283 pub building: BuildingId,
284 pub target_per_tick: f64,
286 pub commodity: CommodityRef,
287 pub fulfillment: f64,
289 pub fulfillment_history: VecDeque<f64>,
291 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum ModifierKind {
330 SkilledLabor,
332 Machinery,
334 Infrastructure,
336 WeatherPenalty,
338 InputShortage,
340 ResearchBonus,
342 OverseerBonus,
344 Corruption,
346}
347
348#[derive(Debug, Clone)]
349pub struct EfficiencyModifier {
350 pub kind: ModifierKind,
351 pub magnitude: f64,
352 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#[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#[derive(Debug, Clone)]
404pub struct Stockpile {
405 pub owner: BuildingId,
406 pub contents: HashMap<CommodityRef, f64>,
408 pub capacities: HashMap<CommodityRef, f64>,
410 pub reserved: HashMap<CommodityRef, f64>,
412 pub total_capacity: f64,
414 pub auto_reorder: bool,
416 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 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 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 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 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 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#[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#[derive(Debug, Clone)]
513pub struct ProcessingBuilding {
514 pub id: BuildingId,
515 pub name: String,
516 pub recipes: Vec<RecipeId>,
518 active_batch: Option<ProductionBatch>,
520 pub recipe_queue: VecDeque<RecipeId>,
522 pub stockpile: Stockpile,
524 pub workers: Vec<WorkerId>,
526 pub worker_slots: u32,
528 pub modifiers: Vec<EfficiencyModifier>,
530 pub quota: Option<ProductionQuota>,
532 pub throughput_history: VecDeque<f64>,
534 pub lifetime_output: HashMap<CommodityRef, f64>,
536 pub operational: bool,
538 pub age_ticks: u64,
540 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 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 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); }
612
613 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 let available: HashMap<CommodityRef, f64> = self.stockpile.contents.clone();
620 if !recipe.can_run(&available) { return false; }
621 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 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 for ing in &recipe.inputs {
646 self.stockpile.consume_reserved(ing.commodity, ing.amount);
647 }
648 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 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 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 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 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 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 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 pub fn halt(&mut self, reason: &str, tick: u64) -> SupplyChainEvent {
793 self.operational = false;
794 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 pub fn resume(&mut self) {
814 self.operational = true;
815 }
816}
817
818#[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 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 self.fatigue = (self.fatigue - 0.01).max(0.0);
866 }
867 }
868}
869
870#[derive(Debug, Clone)]
876pub struct ProductionReport {
877 pub tick: u64,
878 pub total_produced: HashMap<CommodityRef, f64>,
880 pub total_extracted: HashMap<CommodityRef, f64>,
882 pub events: Vec<SupplyChainEvent>,
884 pub buildings_operational: u32,
886 pub nodes_active: u32,
888 pub workers_assigned: u32,
890}
891
892pub 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 pub supply_chain_events: Vec<SupplyChainEvent>,
912
913 rng_state: u64,
915
916 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 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 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 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 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 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 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 pub fn tick(&mut self) -> ProductionReport {
1135 self.current_tick += 1;
1136 let tick = self.current_tick;
1137
1138 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 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 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 let building_ids: Vec<BuildingId> = self.buildings.keys().copied().collect();
1175 let mut buildings_operational = 0u32;
1176 for &bid in &building_ids {
1177 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 let workers_assigned = self.workers.values()
1190 .filter(|w| w.status == WorkerStatus::Assigned)
1191 .count() as u32;
1192
1193 self.supply_chain_events.extend(all_events.iter().cloned());
1195 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 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 pub fn building_stockpile(&self, commodity: CommodityRef) -> f64 {
1230 self.buildings.values()
1231 .map(|b| b.stockpile.amount(commodity))
1232 .sum()
1233 }
1234
1235 pub fn unresolved_events(&self) -> Vec<&SupplyChainEvent> {
1237 self.supply_chain_events.iter().filter(|e| !e.resolved).collect()
1238 }
1239
1240 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 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 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 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 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 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#[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 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 for _ in 0..2 {
1346 let wid = pm.add_worker(1.0);
1347 pm.assign_worker_to_building(wid, bid);
1348 }
1349 pm.buildings.get_mut(&bid).unwrap().stockpile.deposit(ore, 50.0);
1351 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 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 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); 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 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}