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