src/economy.rs 8.6 K · 293 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 overrides: HashMap<Stock, u32>,
109 }
110
111 impl Default for ColonyGoals {
112 fn default() -> Self {
113 let mut goals = Self {
114 goals: HashMap::new(),
115 use_per_day: Stock::ALL
116 .iter()
117 .map(|&s| (s, s.initial_use_per_day()))
118 .collect(),
119 used_this_hour: HashMap::new(),
120 overrides: HashMap::new(),
121 };
122 goals.recompute(&HashMap::new());
123 goals
124 }
125 }
126
127 impl ColonyGoals {
128 #[cfg(test)]
129 pub fn fixed(goals: &[(ItemKind, u32)]) -> Self {
130 Self {
131 goals: goals.iter().map(|&(k, n)| (Stock::of(k), n)).collect(),
132 use_per_day: HashMap::new(),
133 used_this_hour: HashMap::new(),
134 overrides: HashMap::new(),
135 }
136 }
137
138 pub fn goal(&self, stock: Stock) -> u32 {
139 self.overrides
140 .get(&stock)
141 .or_else(|| self.goals.get(&stock))
142 .copied()
143 .unwrap_or(0)
144 }
145
146 pub fn is_overridden(&self, stock: Stock) -> bool {
147 self.overrides.contains_key(&stock)
148 }
149
150 pub fn adjust(&mut self, stock: Stock, delta: i32) {
151 let goal = (self.goal(stock) as i32 + delta).clamp(0, MAX_GOAL as i32) as u32;
152 self.overrides.insert(stock, goal);
153 }
154
155 pub fn reset(&mut self, stock: Stock) {
156 self.overrides.remove(&stock);
157 }
158
159 pub fn use_per_day(&self, stock: Stock) -> f32 {
160 self.use_per_day.get(&stock).copied().unwrap_or(0.0)
161 }
162
163 pub fn record_use(&mut self, kind: ItemKind, count: u32) {
164 *self.used_this_hour.entry(Stock::of(kind)).or_insert(0) += count;
165 }
166
167 pub fn wanted(
168 &self,
169 kind: ItemKind,
170 stocks: &HashMap<ItemKind, u32>,
171 on_the_way: &HashMap<ItemKind, u32>,
172 ) -> bool {
173 let stock = Stock::of(kind);
174 let have: u32 = stock
175 .members()
176 .map(|k| {
177 stocks.get(&k).copied().unwrap_or(0) + on_the_way.get(&k).copied().unwrap_or(0)
178 })
179 .sum();
180 have < self.goal(stock)
181 }
182
183 pub fn learn_hour(&mut self, planned: &HashMap<Stock, u32>) {
184 for stock in Stock::ALL {
185 let used = self.used_this_hour.remove(&stock).unwrap_or(0) as f32;
186 let rate = self.use_per_day.entry(stock).or_insert(0.0);
187 *rate += (used * 24.0 - *rate) / HOURS_OF_MEMORY;
188 }
189 self.recompute(planned);
190 }
191
192 fn recompute(&mut self, planned: &HashMap<Stock, u32>) {
193 for stock in Stock::ALL {
194 let learned = (self.use_per_day(stock) * stock.buffer_days())
195 .clamp(stock.minimum(), MAX_GOAL)
196 .ceil() as u32;
197 let goal = learned + planned.get(&stock).copied().unwrap_or(0);
198 self.goals.insert(stock, goal);
199 }
200 }
201 }
202
203 pub fn in_colony(home: UVec2, tile: UVec2) -> bool {
204 (tile.as_ivec2() - home.as_ivec2()).abs().max_element() <= COLONY_RADIUS
205 }
206
207 pub fn planned_needs(blueprints: &Blueprints) -> HashMap<Stock, u32> {
208 let mut needs = HashMap::new();
209 for kind in blueprints.0.values() {
210 let (item, count) = kind.cost();
211 *needs.entry(Stock::of(item)).or_insert(0) += count;
212 }
213 needs
214 }
215
216 fn learn_needs(tick: Res<SimTick>, blueprints: Res<Blueprints>, mut goals: ResMut<ColonyGoals>) {
217 if tick.0 == 0 || !tick.0.is_multiple_of(TICKS_PER_HOUR) {
218 return;
219 }
220 goals.learn_hour(&planned_needs(&blueprints));
221 }
222
223 #[cfg(test)]
224 mod tests {
225 use super::*;
226 use crate::building::BuildKind;
227
228 fn learn_days(goals: &mut ColonyGoals, kind: ItemKind, per_hour: u32, days: u32) {
229 for _ in 0..days * 24 {
230 goals.record_use(kind, per_hour);
231 goals.learn_hour(&HashMap::new());
232 }
233 }
234
235 #[test]
236 fn targets_follow_what_the_colony_actually_uses() {
237 let mut goals = ColonyGoals::default();
238 learn_days(&mut goals, ItemKind::Meal, 1, 5);
239 let meals = goals.goal(Stock::Meals);
240 assert!((32..=40).contains(&meals), "{meals}");
241 learn_days(&mut goals, ItemKind::Meal, 0, 5);
242 assert_eq!(goals.goal(Stock::Meals), 3);
243 }
244
245 #[test]
246 fn players_can_override_learned_targets_and_hand_them_back() {
247 let mut goals = ColonyGoals::default();
248 let learned = goals.goal(Stock::Wood);
249 goals.adjust(Stock::Wood, 10);
250 assert_eq!(goals.goal(Stock::Wood), learned + 10);
251 learn_days(&mut goals, ItemKind::Wood, 5, 3);
252 assert_eq!(goals.goal(Stock::Wood), learned + 10);
253 goals.adjust(Stock::Iron, -10);
254 assert_eq!(goals.goal(Stock::Iron), 0);
255 goals.reset(Stock::Wood);
256 assert!(!goals.is_overridden(Stock::Wood));
257 assert!(goals.goal(Stock::Wood) > learned);
258 }
259
260 #[test]
261 fn unused_materials_are_not_stockpiled() {
262 let mut goals = ColonyGoals::default();
263 learn_days(&mut goals, ItemKind::Wood, 0, 10);
264 assert_eq!(goals.goal(Stock::Wood), 15);
265 assert_eq!(goals.goal(Stock::Iron), 0);
266 }
267
268 // Raw foods stand in for each other (cooking accepts any), so the colony targets them as one
269 // group and is free to forage, hunt or farm whichever is at hand.
270 #[test]
271 fn raw_foods_share_one_target() {
272 let goals = ColonyGoals::fixed(&[(ItemKind::Berries, 20)]);
273 let stocks = HashMap::from([(ItemKind::Meat, 15), (ItemKind::Vegetables, 4)]);
274 assert!(goals.wanted(ItemKind::Berries, &stocks, &HashMap::new()));
275 let on_the_way = HashMap::from([(ItemKind::Berries, 1)]);
276 assert!(!goals.wanted(ItemKind::Meat, &stocks, &on_the_way));
277 }
278
279 #[test]
280 fn planned_construction_raises_material_targets() {
281 let mut blueprints = Blueprints::default();
282 for x in 0..10 {
283 blueprints.0.insert(
284 UVec2::new(x, 0),
285 BuildKind::Structure(crate::structures::StructureKind::Wall),
286 );
287 }
288 let mut goals = ColonyGoals::default();
289 let before = goals.goal(Stock::Stone);
290 goals.learn_hour(&planned_needs(&blueprints));
291 assert!(goals.goal(Stock::Stone) >= before + 19);
292 }
293 }