Learn stock targets from colony consumption
- 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 | added | +251 -0 |
| src/jobs/mod.rs | +8 -36 | |
| src/jobs/tests.rs | +1 -12 | |
| src/main.rs | +2 -0 | |
| src/selection.rs | +27 -12 |
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%" |