Learn stock targets from colony consumption

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 20:22 UTC
commit
ff2a75e8e8d5586490880c957c299fad21beab62
parent
e45f512883
tree
browse at this commit

5 files changed +289 -60

src/economy.rs +251 -0

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

src/jobs/mod.rs +8 -36

@@ -10,6 +10,7 @@ use crate::{
10 10 animals::{Animal, AnimalKind},
11 11 building::{Blueprints, BuildKind},
12 12 colonist::{Colonist, Path, repath},
13 + economy::ColonyGoals,
13 14 food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food},
14 15 items::{
15 16 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
@@ -32,7 +33,6 @@ impl Plugin for JobsPlugin {
32 33 fn build(&self, app: &mut App) {
33 34 app.insert_resource(SimRng::new(0x5EED))
34 35 .init_resource::<Reservations>()
35 - .init_resource::<ColonyGoals>()
36 36 .add_message::<Order>()
37 37 .add_message::<TileChanged>()
38 38 .add_systems(
@@ -259,34 +259,6 @@ impl Reservations {
259 259 }
260 260 }
261 261
262 -#[derive(Resource, Debug, Clone)]
263 -pub struct ColonyGoals(pub HashMap<ItemKind, u32>);
264 -
265 -impl Default for ColonyGoals {
266 - fn default() -> Self {
267 - Self(HashMap::from([
268 - (ItemKind::Wood, 50),
269 - (ItemKind::Stone, 40),
270 - (ItemKind::Iron, 20),
271 - (ItemKind::Gold, 10),
272 - (ItemKind::Berries, 30),
273 - (ItemKind::Meat, 30),
274 - (ItemKind::Vegetables, 30),
275 - (ItemKind::Meal, 10),
276 - ]))
277 - }
278 -}
279 -
280 -impl ColonyGoals {
281 - pub fn goal(&self, kind: ItemKind) -> u32 {
282 - self.0.get(&kind).copied().unwrap_or(0)
283 - }
284 -
285 - pub fn wanted(&self, kind: ItemKind, in_stock: u32, on_the_way: u32) -> bool {
286 - in_stock + on_the_way < self.goal(kind)
287 - }
288 -}
289 -
290 262 #[derive(Message, Debug, Clone, Copy)]
291 263 pub struct Order {
292 264 pub colonist: Entity,
@@ -860,13 +832,7 @@ fn choose_jobs(
860 832 let snapshot: Vec<(Item, TilePos)> =
861 833 items.iter().map(|(_, item, pos)| (*item, *pos)).collect();
862 834 let refs = snapshot.iter().map(|(i, p)| (i, p));
863 - let wanted = |kind: ItemKind| {
864 - goals.wanted(
865 - kind,
866 - stocks[&kind],
867 - pending.get(&kind).copied().unwrap_or(0),
868 - )
869 - };
835 + let wanted = |kind: ItemKind| goals.wanted(kind, &stocks, &pending);
870 836 let sleep = SleepContext {
871 837 map: &map,
872 838 zones: &zones,
@@ -1002,6 +968,7 @@ fn run_jobs(
1002 968 light: Res<LightMap>,
1003 969 tick: Res<SimTick>,
1004 970 mut blueprints: ResMut<Blueprints>,
971 + mut goals: ResMut<ColonyGoals>,
1005 972 mut items: ItemQuery,
1006 973 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
1007 974 animals: Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>,
@@ -1090,6 +1057,9 @@ fn run_jobs(
1090 1057 if *progress < COOK_WORK {
1091 1058 Step::Continue
1092 1059 } else {
1060 + if let Some(load) = carrying.0 {
1061 + goals.record_use(load.kind, COOK_INPUT);
1062 + }
1093 1063 carrying.0 = carrying.0.and_then(|load| {
1094 1064 (load.count > COOK_INPUT).then_some(Item {
1095 1065 count: load.count - COOK_INPUT,
@@ -1171,6 +1141,7 @@ fn run_jobs(
1171 1141 Step::Continue
1172 1142 } else {
1173 1143 hunger.0 = (hunger.0 + meal.count as f32 * meal.kind.nutrition()).min(1.0);
1144 + goals.record_use(meal.kind, meal.count);
1174 1145 carrying.0 = None;
1175 1146 Step::Done
1176 1147 }
@@ -1326,6 +1297,7 @@ fn run_jobs(
1326 1297 if *progress < kind.work() || someone_in_the_way {
1327 1298 Step::Continue
1328 1299 } else {
1300 + goals.record_use(material, cost);
1329 1301 carrying.0 = carrying.0.and_then(|load| {
1330 1302 (load.count > cost).then_some(Item {
1331 1303 count: load.count - cost,

src/jobs/tests.rs +1 -12

@@ -92,7 +92,7 @@ fn put_wood(app: &mut App, tile: UVec2, count: u32) {
92 92 }
93 93
94 94 fn set_goals(app: &mut App, goals: &[(ItemKind, u32)]) {
95 - *app.world_mut().resource_mut::<ColonyGoals>() = ColonyGoals(goals.iter().copied().collect());
95 + *app.world_mut().resource_mut::<ColonyGoals>() = ColonyGoals::fixed(goals);
96 96 }
97 97
98 98 fn stockpile_size(app: &App) -> usize {
@@ -184,17 +184,6 @@ fn colonists_never_pick_the_same_tree() {
184 184 assert_ne!(target(a), target(b));
185 185 }
186 186
187 -// Each tree or rock being worked will add its yield; counting that as material on the way
188 -// keeps the whole colony from felling a forest when only a little more wood is needed.
189 -#[test]
190 -fn work_in_progress_counts_towards_goals() {
191 - let goals = ColonyGoals(HashMap::from([(ItemKind::Wood, 25)]));
192 - assert!(goals.wanted(ItemKind::Wood, 10, 10));
193 - assert!(!goals.wanted(ItemKind::Wood, 10, 20));
194 - assert!(!goals.wanted(ItemKind::Wood, 30, 0));
195 - assert!(!goals.wanted(ItemKind::Gold, 0, 0));
196 -}
197 -
198 187 #[test]
199 188 fn ordered_chop_ignores_the_wood_goal() {
200 189 let tree = UVec2::new(8, 5);

src/main.rs +2 -0

@@ -3,6 +3,7 @@ mod animals;
3 3 mod building;
4 4 mod camera;
5 5 mod colonist;
6 +mod economy;
6 7 mod farming;
7 8 mod food;
8 9 mod items;
@@ -60,6 +61,7 @@ fn main() {
60 61 animals::AnimalsPlugin,
61 62 farming::FarmingPlugin,
62 63 paths::PathsPlugin,
64 + economy::EconomyPlugin,
63 65 plants::PlantsPlugin,
64 66 selection::SelectionPlugin,
65 67 zones::ZonesPlugin,

src/selection.rs +27 -12

@@ -6,9 +6,10 @@ use crate::{
6 6 building::Blueprints,
7 7 camera::CameraController,
8 8 colonist::{Colonist, Path},
9 + economy::{ColonyGoals, Stock},
9 10 food::Hunger,
10 - items::{Carrying, Item, ItemKind, stock},
11 - jobs::{COOK_WORK, ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
11 + items::{Carrying, Item, stock},
12 + jobs::{COOK_WORK, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
12 13 light::daylight,
13 14 needs::Rest,
14 15 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
@@ -286,15 +287,20 @@ fn update_tile_info(
286 287 let speed = describe_speed(*speed, time.is_paused());
287 288 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
288 289 let stock_line = |food: bool| {
289 - ItemKind::ALL
290 + Stock::ALL
290 291 .iter()
291 - .filter(|kind| kind.is_food() == food)
292 - .map(|&kind| {
293 - let have = stock(
294 - items.iter().map(|(item, _)| item).chain(carried.iter()),
295 - kind,
296 - );
297 - format!("{kind:?} {have}/{}", goals.goal(kind))
292 + .filter(|s| s.is_food() == food)
293 + .map(|&s| {
294 + let have: u32 = s
295 + .members()
296 + .map(|kind| {
297 + stock(
298 + items.iter().map(|(item, _)| item).chain(carried.iter()),
299 + kind,
300 + )
301 + })
302 + .sum();
303 + format!("{} {have}/{}", s.label(), goals.goal(s))
298 304 })
299 305 .collect::<Vec<_>>()
300 306 .join(" ")
@@ -308,7 +314,7 @@ fn update_tile_info(
308 314 let hovered_items: String = items
309 315 .iter()
310 316 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
311 - .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
317 + .map(|(item, _)| describe_item(item))
312 318 .collect();
313 319 let hovered_structure: String = structures
314 320 .iter()
@@ -368,6 +374,15 @@ pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
368 374 )
369 375 }
370 376
377 +pub fn describe_item(item: &Item) -> String {
378 + let spoil = if item.kind.is_food() && item.spoil >= 0.01 {
379 + format!(" ({:.0}% spoiled)", item.spoil * 100.0)
380 + } else {
381 + String::new()
382 + };
383 + format!(", {:?} x{}{spoil}", item.kind, item.count)
384 +}
385 +
371 386 pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
372 387 if paused {
373 388 "PAUSED".into()
@@ -741,7 +756,7 @@ mod tests {
741 756 },
742 757 ordered: false,
743 758 };
744 - let meal = Item::new(ItemKind::Meal, 1);
759 + let meal = Item::new(crate::items::ItemKind::Meal, 1);
745 760 assert_eq!(
746 761 describe_colonist(&view(Some(eat), 0, Some(meal))),
747 762 "Ada at (1, 2), eating, carrying 1 Meal, rest 100%, food 50%"