src/economy.rs 7.4 K · 258 lines · raw · history

1 use std::collections::HashMap;
2
3 use bevy::prelude::*;
4
5 use crate::{
6 building::Blueprints,
7 items::ItemKind,
8 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
9 };
10
11 pub struct EconomyPlugin;
12
13 impl Plugin for EconomyPlugin {
14 fn build(&self, app: &mut App) {
15 app.init_resource::<ColonyGoals>()
16 .add_systems(FixedUpdate, learn_needs.in_set(SimSystems::Decide));
17 }
18 }
19
20 pub const COLONY_RADIUS: i32 = 60;
21 const HOURS_OF_MEMORY: f32 = 24.0;
22 const MAX_GOAL: f32 = 300.0;
23
24 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25 pub enum Stock {
26 Wood,
27 Stone,
28 Iron,
29 Gold,
30 RawFood,
31 Meals,
32 }
33
34 impl Stock {
35 pub const ALL: [Stock; 6] = [
36 Stock::Wood,
37 Stock::Stone,
38 Stock::Iron,
39 Stock::Gold,
40 Stock::RawFood,
41 Stock::Meals,
42 ];
43
44 pub fn of(kind: ItemKind) -> Stock {
45 match kind {
46 ItemKind::Wood => Stock::Wood,
47 ItemKind::Stone => Stock::Stone,
48 ItemKind::Iron => Stock::Iron,
49 ItemKind::Gold => Stock::Gold,
50 ItemKind::Berries | ItemKind::Meat | ItemKind::Vegetables | ItemKind::Carcass => {
51 Stock::RawFood
52 }
53 ItemKind::Meal => Stock::Meals,
54 }
55 }
56
57 pub fn members(self) -> impl Iterator<Item = ItemKind> {
58 ItemKind::ALL
59 .into_iter()
60 .filter(move |&k| Stock::of(k) == self)
61 }
62
63 pub fn is_food(self) -> bool {
64 matches!(self, Stock::RawFood | Stock::Meals)
65 }
66
67 pub fn label(self) -> &'static str {
68 match self {
69 Stock::Wood => "Wood",
70 Stock::Stone => "Stone",
71 Stock::Iron => "Iron",
72 Stock::Gold => "Gold",
73 Stock::RawFood => "Raw food",
74 Stock::Meals => "Meals",
75 }
76 }
77
78 fn buffer_days(self) -> f32 {
79 if self.is_food() { 1.5 } else { 2.0 }
80 }
81
82 fn minimum(self) -> f32 {
83 match self {
84 Stock::Wood => 15.0,
85 Stock::Stone => 5.0,
86 Stock::Iron | Stock::Gold => 0.0,
87 Stock::RawFood => 10.0,
88 Stock::Meals => 3.0,
89 }
90 }
91
92 fn initial_use_per_day(self) -> f32 {
93 match self {
94 Stock::Wood => 20.0,
95 Stock::Stone => 10.0,
96 Stock::Iron | Stock::Gold => 0.0,
97 Stock::RawFood => 30.0,
98 Stock::Meals => 3.0,
99 }
100 }
101 }
102
103 #[derive(Resource, Debug, Clone)]
104 pub struct ColonyGoals {
105 goals: HashMap<Stock, u32>,
106 use_per_day: HashMap<Stock, f32>,
107 used_this_hour: HashMap<Stock, u32>,
108 }
109
110 impl Default for ColonyGoals {
111 fn default() -> Self {
112 let mut goals = Self {
113 goals: HashMap::new(),
114 use_per_day: Stock::ALL
115 .iter()
116 .map(|&s| (s, s.initial_use_per_day()))
117 .collect(),
118 used_this_hour: HashMap::new(),
119 };
120 goals.recompute(&HashMap::new());
121 goals
122 }
123 }
124
125 impl ColonyGoals {
126 #[cfg(test)]
127 pub fn fixed(goals: &[(ItemKind, u32)]) -> Self {
128 Self {
129 goals: goals.iter().map(|&(k, n)| (Stock::of(k), n)).collect(),
130 use_per_day: HashMap::new(),
131 used_this_hour: HashMap::new(),
132 }
133 }
134
135 pub fn goal(&self, stock: Stock) -> u32 {
136 self.goals.get(&stock).copied().unwrap_or(0)
137 }
138
139 pub fn use_per_day(&self, stock: Stock) -> f32 {
140 self.use_per_day.get(&stock).copied().unwrap_or(0.0)
141 }
142
143 pub fn record_use(&mut self, kind: ItemKind, count: u32) {
144 *self.used_this_hour.entry(Stock::of(kind)).or_insert(0) += count;
145 }
146
147 pub fn wanted(
148 &self,
149 kind: ItemKind,
150 stocks: &HashMap<ItemKind, u32>,
151 on_the_way: &HashMap<ItemKind, u32>,
152 ) -> bool {
153 let stock = Stock::of(kind);
154 let have: u32 = stock
155 .members()
156 .map(|k| {
157 stocks.get(&k).copied().unwrap_or(0) + on_the_way.get(&k).copied().unwrap_or(0)
158 })
159 .sum();
160 have < self.goal(stock)
161 }
162
163 pub fn learn_hour(&mut self, planned: &HashMap<Stock, u32>) {
164 for stock in Stock::ALL {
165 let used = self.used_this_hour.remove(&stock).unwrap_or(0) as f32;
166 let rate = self.use_per_day.entry(stock).or_insert(0.0);
167 *rate += (used * 24.0 - *rate) / HOURS_OF_MEMORY;
168 }
169 self.recompute(planned);
170 }
171
172 fn recompute(&mut self, planned: &HashMap<Stock, u32>) {
173 for stock in Stock::ALL {
174 let learned = (self.use_per_day(stock) * stock.buffer_days())
175 .clamp(stock.minimum(), MAX_GOAL)
176 .ceil() as u32;
177 let goal = learned + planned.get(&stock).copied().unwrap_or(0);
178 self.goals.insert(stock, goal);
179 }
180 }
181 }
182
183 pub fn in_colony(home: UVec2, tile: UVec2) -> bool {
184 (tile.as_ivec2() - home.as_ivec2()).abs().max_element() <= COLONY_RADIUS
185 }
186
187 pub fn planned_needs(blueprints: &Blueprints) -> HashMap<Stock, u32> {
188 let mut needs = HashMap::new();
189 for kind in blueprints.0.values() {
190 let (item, count) = kind.cost();
191 *needs.entry(Stock::of(item)).or_insert(0) += count;
192 }
193 needs
194 }
195
196 fn learn_needs(tick: Res<SimTick>, blueprints: Res<Blueprints>, mut goals: ResMut<ColonyGoals>) {
197 if tick.0 == 0 || !tick.0.is_multiple_of(TICKS_PER_HOUR) {
198 return;
199 }
200 goals.learn_hour(&planned_needs(&blueprints));
201 }
202
203 #[cfg(test)]
204 mod tests {
205 use super::*;
206 use crate::building::BuildKind;
207
208 fn learn_days(goals: &mut ColonyGoals, kind: ItemKind, per_hour: u32, days: u32) {
209 for _ in 0..days * 24 {
210 goals.record_use(kind, per_hour);
211 goals.learn_hour(&HashMap::new());
212 }
213 }
214
215 #[test]
216 fn targets_follow_what_the_colony_actually_uses() {
217 let mut goals = ColonyGoals::default();
218 learn_days(&mut goals, ItemKind::Meal, 1, 5);
219 let meals = goals.goal(Stock::Meals);
220 assert!((32..=40).contains(&meals), "{meals}");
221 learn_days(&mut goals, ItemKind::Meal, 0, 5);
222 assert_eq!(goals.goal(Stock::Meals), 3);
223 }
224
225 #[test]
226 fn unused_materials_are_not_stockpiled() {
227 let mut goals = ColonyGoals::default();
228 learn_days(&mut goals, ItemKind::Wood, 0, 10);
229 assert_eq!(goals.goal(Stock::Wood), 15);
230 assert_eq!(goals.goal(Stock::Iron), 0);
231 }
232
233 // Raw foods stand in for each other (cooking accepts any), so the colony targets them as one
234 // group and is free to forage, hunt or farm whichever is at hand.
235 #[test]
236 fn raw_foods_share_one_target() {
237 let goals = ColonyGoals::fixed(&[(ItemKind::Berries, 20)]);
238 let stocks = HashMap::from([(ItemKind::Meat, 15), (ItemKind::Vegetables, 4)]);
239 assert!(goals.wanted(ItemKind::Berries, &stocks, &HashMap::new()));
240 let on_the_way = HashMap::from([(ItemKind::Berries, 1)]);
241 assert!(!goals.wanted(ItemKind::Meat, &stocks, &on_the_way));
242 }
243
244 #[test]
245 fn planned_construction_raises_material_targets() {
246 let mut blueprints = Blueprints::default();
247 for x in 0..10 {
248 blueprints.0.insert(
249 UVec2::new(x, 0),
250 BuildKind::Structure(crate::structures::StructureKind::Wall),
251 );
252 }
253 let mut goals = ColonyGoals::default();
254 let before = goals.goal(Stock::Stone);
255 goals.learn_hour(&planned_needs(&blueprints));
256 assert!(goals.goal(Stock::Stone) >= before + 19);
257 }
258 }