src/economy.rs 10 K · 334 lines · raw · history

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