Add stockpile filters, storage priority and stock target overrides
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:34 UTC
- commit
- 2bf37c74db18271ffd648c8f88029d2a76c8e9b0
- parent
- c5508e751f
- tree
- browse at this commit
7 files changed +431 -17
| CLAUDE.md | +2 -1 | |
| src/economy.rs | +36 -1 | |
| src/jobs/mod.rs | +31 -9 | |
| src/jobs/tests.rs | +31 -1 | |
| src/panel.rs | +204 -2 | |
| src/zones.rs | +125 -1 | |
| tasks.md | +2 -2 |
CLAUDE.md +2 -1
| @@ -44,7 +44,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 44 | 44 | - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (≤ MAX_COLONISTS, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home. |
| 45 | 45 | - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): drops homes of extinct families; roofs finished homes (`roofs_needed`: interior enclosed exactly); plans one home per partnered family without one via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt free of stockpile/farm/blueprints/homes) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes. Selection text shows partner / child days left. |
| 46 | 46 | - `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick. |
| 47 | -- `src/panel.rs` top-right colonist panel: roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities. | |
| 47 | +- Storage rules (zones.rs): `Zones.rules` per stockpile tile `StorageRule{refused: Vec<Stock>, priority: StoragePriority::{Low, Normal, High}}` (absent = default), `accepts(tile, kind)`, `update_region_rule` applies to the 4-connected stockpile region; removing tiles drops rules. `find_storage` tries priorities High→Normal→Low, nearest accepting free tile with room; `find_haul` treats items on tiles that don't accept them as loose (no re-hauling between priorities). `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals. | |
| 48 | +- `src/panel.rs` top-right colonist panel (also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities. | |
| 48 | 49 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 49 | 50 | |
| 50 | 51 | ## Gotchas |
src/economy.rs +36 -1
| @@ -105,6 +105,7 @@ pub struct ColonyGoals { | ||
| 105 | 105 | goals: HashMap<Stock, u32>, |
| 106 | 106 | use_per_day: HashMap<Stock, f32>, |
| 107 | 107 | used_this_hour: HashMap<Stock, u32>, |
| 108 | + overrides: HashMap<Stock, u32>, | |
| 108 | 109 | } |
| 109 | 110 | |
| 110 | 111 | impl Default for ColonyGoals { |
| @@ -116,6 +117,7 @@ impl Default for ColonyGoals { | ||
| 116 | 117 | .map(|&s| (s, s.initial_use_per_day())) |
| 117 | 118 | .collect(), |
| 118 | 119 | used_this_hour: HashMap::new(), |
| 120 | + overrides: HashMap::new(), | |
| 119 | 121 | }; |
| 120 | 122 | goals.recompute(&HashMap::new()); |
| 121 | 123 | goals |
| @@ -129,11 +131,29 @@ impl ColonyGoals { | ||
| 129 | 131 | goals: goals.iter().map(|&(k, n)| (Stock::of(k), n)).collect(), |
| 130 | 132 | use_per_day: HashMap::new(), |
| 131 | 133 | used_this_hour: HashMap::new(), |
| 134 | + overrides: HashMap::new(), | |
| 132 | 135 | } |
| 133 | 136 | } |
| 134 | 137 | |
| 135 | 138 | pub fn goal(&self, stock: Stock) -> u32 { |
| 136 | - self.goals.get(&stock).copied().unwrap_or(0) | |
| 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); | |
| 137 | 157 | } |
| 138 | 158 | |
| 139 | 159 | pub fn use_per_day(&self, stock: Stock) -> f32 { |
| @@ -223,6 +243,21 @@ mod tests { | ||
| 223 | 243 | } |
| 224 | 244 | |
| 225 | 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] | |
| 226 | 261 | fn unused_materials_are_not_stockpiled() { |
| 227 | 262 | let mut goals = ColonyGoals::default(); |
| 228 | 263 | learn_days(&mut goals, ItemKind::Wood, 0, 10); |
src/jobs/mod.rs +31 -9
| @@ -15,8 +15,8 @@ use crate::{ | ||
| 15 | 15 | family::{Child, Family, Homes}, |
| 16 | 16 | food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food}, |
| 17 | 17 | items::{ |
| 18 | - CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for, | |
| 19 | - stock, take_items, | |
| 18 | + CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items, | |
| 19 | + room_for, stock, take_items, | |
| 20 | 20 | }, |
| 21 | 21 | light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from, line_of_sight}, |
| 22 | 22 | mood::{Ate, Sick}, |
| @@ -27,7 +27,9 @@ use crate::{ | ||
| 27 | 27 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 28 | 28 | world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, |
| 29 | 29 | worldgen::WorldGenerated, |
| 30 | - zones::{Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion}, | |
| 30 | + zones::{ | |
| 31 | + StoragePriority, Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion, | |
| 32 | + }, | |
| 31 | 33 | }; |
| 32 | 34 | |
| 33 | 35 | pub struct JobsPlugin; |
| @@ -462,11 +464,29 @@ pub fn find_storage<'a>( | ||
| 462 | 464 | from: UVec2, |
| 463 | 465 | kind: ItemKind, |
| 464 | 466 | ) -> Option<UVec2> { |
| 465 | - nearest_tile(map, from, SEARCH_TILES, |p| { | |
| 466 | - zones.is_stockpile(p) | |
| 467 | - && reservations.is_free(Target::Storage(p)) | |
| 468 | - && room_for(items.clone().into_iter(), p, kind) > 0 | |
| 469 | - }) | |
| 467 | + let mut stacks: HashMap<UVec2, (ItemKind, u32)> = HashMap::new(); | |
| 468 | + for (item, pos) in items { | |
| 469 | + let entry = stacks.entry(pos.0).or_insert((item.kind, 0)); | |
| 470 | + entry.1 += item.count; | |
| 471 | + } | |
| 472 | + let room = |tile: UVec2| match stacks.get(&tile) { | |
| 473 | + None => true, | |
| 474 | + Some(&(other, count)) => other == kind && count < STACK_LIMIT, | |
| 475 | + }; | |
| 476 | + StoragePriority::DESCENDING | |
| 477 | + .into_iter() | |
| 478 | + .find_map(|priority| { | |
| 479 | + let candidates: HashSet<UVec2> = zones | |
| 480 | + .stockpile_tiles() | |
| 481 | + .filter(|&t| { | |
| 482 | + zones.rule(t).priority == priority | |
| 483 | + && zones.accepts(t, kind) | |
| 484 | + && reservations.is_free(Target::Storage(t)) | |
| 485 | + && room(t) | |
| 486 | + }) | |
| 487 | + .collect(); | |
| 488 | + nearest_of(map, from, SEARCH_TILES, &candidates) | |
| 489 | + }) | |
| 470 | 490 | } |
| 471 | 491 | |
| 472 | 492 | pub fn find_haul( |
| @@ -478,8 +498,10 @@ pub fn find_haul( | ||
| 478 | 498 | ) -> Option<Task> { |
| 479 | 499 | let loose: HashSet<UVec2> = items |
| 480 | 500 | .iter() |
| 501 | + .filter(|(item, pos)| { | |
| 502 | + !zones.accepts(pos.0, item.kind) && reservations.is_free(Target::Pickup(pos.0)) | |
| 503 | + }) | |
| 481 | 504 | .map(|(_, pos)| pos.0) |
| 482 | - .filter(|&p| !zones.is_stockpile(p) && reservations.is_free(Target::Pickup(p))) | |
| 483 | 505 | .collect(); |
| 484 | 506 | if loose.is_empty() { |
| 485 | 507 | return None; |
src/jobs/tests.rs +31 -1
| @@ -4,7 +4,7 @@ use super::*; | ||
| 4 | 4 | use crate::{ |
| 5 | 5 | animals::{Animal, AnimalKind}, |
| 6 | 6 | colonist::follow_path, |
| 7 | - economy::COLONY_RADIUS, | |
| 7 | + economy::{COLONY_RADIUS, Stock}, | |
| 8 | 8 | food::Hunger, |
| 9 | 9 | items::STACK_LIMIT, |
| 10 | 10 | sim::{SimPos, sim_pos}, |
| @@ -1326,3 +1326,33 @@ fn colonists_skip_work_types_they_are_not_assigned() { | ||
| 1326 | 1326 | run_ticks(&mut app, 60 * 15); |
| 1327 | 1327 | assert!(items_of(&mut app, ItemKind::Wood) > 0); |
| 1328 | 1328 | } |
| 1329 | + | |
| 1330 | +#[test] | |
| 1331 | +fn hauled_items_go_to_the_highest_priority_stockpile_that_accepts_them() { | |
| 1332 | + let mut app = test_app(map_with_trees(&[])); | |
| 1333 | + set_goals(&mut app, &[]); | |
| 1334 | + put_beds(&mut app, 1); | |
| 1335 | + let map = app.world().resource::<WorldMap>().clone(); | |
| 1336 | + { | |
| 1337 | + let mut zones = app.world_mut().resource_mut::<Zones>(); | |
| 1338 | + zones.add_stockpile(&map, IVec2::new(3, 3), IVec2::new(4, 4)); | |
| 1339 | + zones.add_stockpile(&map, IVec2::new(15, 15), IVec2::new(16, 16)); | |
| 1340 | + zones.add_stockpile(&map, IVec2::new(15, 3), IVec2::new(16, 4)); | |
| 1341 | + zones.update_region_rule(UVec2::new(15, 15), |rule| { | |
| 1342 | + rule.priority = crate::zones::StoragePriority::High | |
| 1343 | + }); | |
| 1344 | + zones.update_region_rule(UVec2::new(3, 3), |rule| rule.toggle(Stock::Stone)); | |
| 1345 | + } | |
| 1346 | + put_items(&mut app, UVec2::new(3, 3), ItemKind::Stone, 5); | |
| 1347 | + put_items(&mut app, UVec2::new(6, 6), ItemKind::Wood, 5); | |
| 1348 | + spawn_colonist(&mut app, UVec2::new(5, 5)); | |
| 1349 | + run_ticks(&mut app, 60 * 30); | |
| 1350 | + let world = app.world_mut(); | |
| 1351 | + let placed: Vec<(ItemKind, UVec2)> = world | |
| 1352 | + .query::<(&Item, &TilePos)>() | |
| 1353 | + .iter(world) | |
| 1354 | + .map(|(i, p)| (i.kind, p.0)) | |
| 1355 | + .collect(); | |
| 1356 | + let in_high = |t: UVec2| t.x >= 15 && t.y >= 15; | |
| 1357 | + assert!(placed.iter().all(|(_, t)| in_high(*t)), "{placed:?}"); | |
| 1358 | +} | |
src/panel.rs +204 -2
| @@ -6,11 +6,13 @@ use bevy::{ | ||
| 6 | 6 | |
| 7 | 7 | use crate::{ |
| 8 | 8 | colonist::Colonist, |
| 9 | + economy::{ColonyGoals, Stock}, | |
| 9 | 10 | family::Child, |
| 10 | 11 | jobs::{CurrentJob, WorkPriorities, WorkType}, |
| 11 | 12 | mood::Mood, |
| 12 | 13 | selection::Selection, |
| 13 | 14 | ui::{BUTTON_ACTIVE_COLOR, BUTTON_COLOR, BUTTON_HOVER_COLOR, HideableUi}, |
| 15 | + zones::Zones, | |
| 14 | 16 | }; |
| 15 | 17 | |
| 16 | 18 | pub struct PanelPlugin; |
| @@ -20,7 +22,13 @@ impl Plugin for PanelPlugin { | ||
| 20 | 22 | app.add_systems(Startup, spawn_panel) |
| 21 | 23 | .add_systems( |
| 22 | 24 | Update, |
| 23 | - (rebuild_roster, show_work_toggles, style_panel_buttons), | |
| 25 | + ( | |
| 26 | + rebuild_roster, | |
| 27 | + show_work_toggles, | |
| 28 | + style_panel_buttons, | |
| 29 | + show_storage, | |
| 30 | + show_targets, | |
| 31 | + ), | |
| 24 | 32 | ) |
| 25 | 33 | .add_observer(on_panel_activated); |
| 26 | 34 | } |
| @@ -41,6 +49,45 @@ struct ColonistButton(Entity); | ||
| 41 | 49 | #[derive(Component, Clone, Copy)] |
| 42 | 50 | struct WorkToggle(WorkType); |
| 43 | 51 | |
| 52 | +#[derive(Component)] | |
| 53 | +struct StorageSection; | |
| 54 | + | |
| 55 | +#[derive(Component, Clone, Copy)] | |
| 56 | +struct StorageToggle(Stock); | |
| 57 | + | |
| 58 | +#[derive(Component)] | |
| 59 | +struct PriorityButton; | |
| 60 | + | |
| 61 | +#[derive(Component)] | |
| 62 | +struct PriorityLabel; | |
| 63 | + | |
| 64 | +#[derive(Component)] | |
| 65 | +struct TargetsToggle; | |
| 66 | + | |
| 67 | +#[derive(Component)] | |
| 68 | +struct TargetsSection; | |
| 69 | + | |
| 70 | +#[derive(Component, Clone, Copy)] | |
| 71 | +struct GoalText(Stock); | |
| 72 | + | |
| 73 | +#[derive(Component, Clone, Copy)] | |
| 74 | +struct GoalButton { | |
| 75 | + stock: Stock, | |
| 76 | + delta: i32, | |
| 77 | +} | |
| 78 | + | |
| 79 | +const GOAL_STEP: i32 = 10; | |
| 80 | + | |
| 81 | +fn wrap_row() -> Node { | |
| 82 | + Node { | |
| 83 | + flex_wrap: FlexWrap::Wrap, | |
| 84 | + column_gap: px(3), | |
| 85 | + row_gap: px(3), | |
| 86 | + align_items: AlignItems::Center, | |
| 87 | + ..default() | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 44 | 91 | fn small_button(label: String) -> impl Bundle { |
| 45 | 92 | ( |
| 46 | 93 | Button, |
| @@ -105,6 +152,136 @@ fn spawn_panel(mut commands: Commands) { | ||
| 105 | 152 | ChildOf(row), |
| 106 | 153 | )); |
| 107 | 154 | } |
| 155 | + let storage = commands | |
| 156 | + .spawn(( | |
| 157 | + StorageSection, | |
| 158 | + Node { | |
| 159 | + display: Display::None, | |
| 160 | + ..wrap_row() | |
| 161 | + }, | |
| 162 | + ChildOf(panel), | |
| 163 | + children![( | |
| 164 | + Text::new("Stockpile accepts:"), | |
| 165 | + TextFont::from_font_size(11.0) | |
| 166 | + )], | |
| 167 | + )) | |
| 168 | + .id(); | |
| 169 | + for stock in Stock::ALL { | |
| 170 | + commands.spawn(( | |
| 171 | + small_button(stock.label().to_string()), | |
| 172 | + StorageToggle(stock), | |
| 173 | + ChildOf(storage), | |
| 174 | + )); | |
| 175 | + } | |
| 176 | + let priority = commands | |
| 177 | + .spawn(( | |
| 178 | + small_button(String::new()), | |
| 179 | + PriorityButton, | |
| 180 | + ChildOf(storage), | |
| 181 | + )) | |
| 182 | + .id(); | |
| 183 | + commands.spawn(( | |
| 184 | + PriorityLabel, | |
| 185 | + Text::new("Priority"), | |
| 186 | + TextFont::from_font_size(11.0), | |
| 187 | + ChildOf(priority), | |
| 188 | + )); | |
| 189 | + commands.spawn(( | |
| 190 | + small_button("Stock targets".into()), | |
| 191 | + TargetsToggle, | |
| 192 | + ChildOf(panel), | |
| 193 | + )); | |
| 194 | + let targets = commands | |
| 195 | + .spawn(( | |
| 196 | + TargetsSection, | |
| 197 | + Node { | |
| 198 | + display: Display::None, | |
| 199 | + flex_direction: FlexDirection::Column, | |
| 200 | + row_gap: px(2), | |
| 201 | + ..default() | |
| 202 | + }, | |
| 203 | + ChildOf(panel), | |
| 204 | + )) | |
| 205 | + .id(); | |
| 206 | + for stock in Stock::ALL { | |
| 207 | + let line = commands.spawn((wrap_row(), ChildOf(targets))).id(); | |
| 208 | + commands.spawn(( | |
| 209 | + GoalText(stock), | |
| 210 | + Text::new(stock.label()), | |
| 211 | + TextFont::from_font_size(11.0), | |
| 212 | + Node { | |
| 213 | + width: px(78), | |
| 214 | + ..default() | |
| 215 | + }, | |
| 216 | + ChildOf(line), | |
| 217 | + )); | |
| 218 | + for (label, delta) in [("-", -GOAL_STEP), ("+", GOAL_STEP), ("auto", 0)] { | |
| 219 | + commands.spawn(( | |
| 220 | + small_button(label.into()), | |
| 221 | + GoalButton { stock, delta }, | |
| 222 | + ChildOf(line), | |
| 223 | + )); | |
| 224 | + } | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +fn selected_stockpile(selection: &Selection, zones: &Zones) -> Option<UVec2> { | |
| 229 | + match *selection { | |
| 230 | + Selection::Tile(tile) if zones.is_stockpile(tile) => Some(tile), | |
| 231 | + _ => None, | |
| 232 | + } | |
| 233 | +} | |
| 234 | + | |
| 235 | +fn show_storage( | |
| 236 | + selection: Res<Selection>, | |
| 237 | + zones: Res<Zones>, | |
| 238 | + mut section: Single<&mut Node, With<StorageSection>>, | |
| 239 | + mut toggles: Query<(&StorageToggle, &mut BackgroundColor)>, | |
| 240 | + mut label: Single<&mut Text, With<PriorityLabel>>, | |
| 241 | +) { | |
| 242 | + let tile = selected_stockpile(&selection, &zones); | |
| 243 | + let display = if tile.is_some() { | |
| 244 | + Display::Flex | |
| 245 | + } else { | |
| 246 | + Display::None | |
| 247 | + }; | |
| 248 | + if section.display != display { | |
| 249 | + section.display = display; | |
| 250 | + } | |
| 251 | + let Some(tile) = tile else { | |
| 252 | + return; | |
| 253 | + }; | |
| 254 | + let rule = zones.rule(tile); | |
| 255 | + for (toggle, mut color) in &mut toggles { | |
| 256 | + let accepted = !rule.refused.contains(&toggle.0); | |
| 257 | + let new = if accepted { | |
| 258 | + BUTTON_ACTIVE_COLOR | |
| 259 | + } else { | |
| 260 | + DISABLED_COLOR | |
| 261 | + }; | |
| 262 | + color.set_if_neq(BackgroundColor(new)); | |
| 263 | + } | |
| 264 | + let text = format!("Priority: {}", rule.priority.label()); | |
| 265 | + if label.0 != text { | |
| 266 | + label.0 = text; | |
| 267 | + } | |
| 268 | +} | |
| 269 | + | |
| 270 | +fn show_targets( | |
| 271 | + goals: Res<ColonyGoals>, | |
| 272 | + section: Single<&Node, With<TargetsSection>>, | |
| 273 | + mut texts: Query<(&GoalText, &mut Text)>, | |
| 274 | +) { | |
| 275 | + if section.display == Display::None { | |
| 276 | + return; | |
| 277 | + } | |
| 278 | + for (goal, mut text) in &mut texts { | |
| 279 | + let marker = if goals.is_overridden(goal.0) { "*" } else { "" }; | |
| 280 | + let line = format!("{} {}{marker}", goal.0.label(), goals.goal(goal.0)); | |
| 281 | + if text.0 != line { | |
| 282 | + text.0 = line; | |
| 283 | + } | |
| 284 | + } | |
| 108 | 285 | } |
| 109 | 286 | |
| 110 | 287 | pub fn roster_label(name: &str, child: bool, mood: Option<f32>) -> String { |
| @@ -194,14 +371,39 @@ fn style_panel_buttons( | ||
| 194 | 371 | } |
| 195 | 372 | } |
| 196 | 373 | |
| 374 | +#[allow(clippy::too_many_arguments)] | |
| 197 | 375 | fn on_panel_activated( |
| 198 | 376 | activate: On<Activate>, |
| 199 | 377 | toggles: Query<&WorkToggle>, |
| 200 | 378 | colonist_buttons: Query<&ColonistButton>, |
| 379 | + storage: Query<(Option<&StorageToggle>, Has<PriorityButton>)>, | |
| 380 | + goal_buttons: Query<&GoalButton>, | |
| 381 | + targets_toggle: Query<(), With<TargetsToggle>>, | |
| 382 | + mut targets: Single<&mut Node, With<TargetsSection>>, | |
| 201 | 383 | mut selection: ResMut<Selection>, |
| 384 | + mut zones: ResMut<Zones>, | |
| 385 | + mut goals: ResMut<ColonyGoals>, | |
| 202 | 386 | mut priorities: Query<&mut WorkPriorities>, |
| 203 | 387 | ) { |
| 204 | - if let Ok(button) = colonist_buttons.get(activate.entity) { | |
| 388 | + if targets_toggle.contains(activate.entity) { | |
| 389 | + targets.display = match targets.display { | |
| 390 | + Display::None => Display::Flex, | |
| 391 | + _ => Display::None, | |
| 392 | + }; | |
| 393 | + } else if let Ok(button) = goal_buttons.get(activate.entity) { | |
| 394 | + match button.delta { | |
| 395 | + 0 => goals.reset(button.stock), | |
| 396 | + delta => goals.adjust(button.stock, delta), | |
| 397 | + } | |
| 398 | + } else if let Ok((toggle, priority)) = storage.get(activate.entity) | |
| 399 | + && (toggle.is_some() || priority) | |
| 400 | + && let Some(tile) = selected_stockpile(&selection, &zones) | |
| 401 | + { | |
| 402 | + zones.update_region_rule(tile, |rule| match toggle { | |
| 403 | + Some(toggle) => rule.toggle(toggle.0), | |
| 404 | + None => rule.priority = rule.priority.next(), | |
| 405 | + }); | |
| 406 | + } else if let Ok(button) = colonist_buttons.get(activate.entity) { | |
| 205 | 407 | *selection = Selection::Colonist(button.0); |
| 206 | 408 | } else if let Ok(toggle) = toggles.get(activate.entity) |
| 207 | 409 | && let Selection::Colonist(entity) = *selection |
src/zones.rs +125 -1
| @@ -1,9 +1,11 @@ | ||
| 1 | -use std::collections::HashSet; | |
| 1 | +use std::collections::{HashMap, HashSet}; | |
| 2 | 2 | |
| 3 | 3 | use bevy::prelude::*; |
| 4 | 4 | |
| 5 | 5 | use crate::{ |
| 6 | 6 | actions::Action, |
| 7 | + economy::Stock, | |
| 8 | + items::ItemKind, | |
| 7 | 9 | pathfinding::nearest_tile, |
| 8 | 10 | plants::fertility, |
| 9 | 11 | selection::{Cursor, update_cursor}, |
| @@ -37,13 +39,111 @@ pub enum ZoneKind { | ||
| 37 | 39 | Farm, |
| 38 | 40 | } |
| 39 | 41 | |
| 42 | +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 43 | +pub enum StoragePriority { | |
| 44 | + Low, | |
| 45 | + #[default] | |
| 46 | + Normal, | |
| 47 | + High, | |
| 48 | +} | |
| 49 | + | |
| 50 | +impl StoragePriority { | |
| 51 | + pub const DESCENDING: [StoragePriority; 3] = [ | |
| 52 | + StoragePriority::High, | |
| 53 | + StoragePriority::Normal, | |
| 54 | + StoragePriority::Low, | |
| 55 | + ]; | |
| 56 | + | |
| 57 | + pub fn next(self) -> Self { | |
| 58 | + match self { | |
| 59 | + StoragePriority::Low => StoragePriority::Normal, | |
| 60 | + StoragePriority::Normal => StoragePriority::High, | |
| 61 | + StoragePriority::High => StoragePriority::Low, | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + pub fn label(self) -> &'static str { | |
| 66 | + match self { | |
| 67 | + StoragePriority::Low => "Low", | |
| 68 | + StoragePriority::Normal => "Normal", | |
| 69 | + StoragePriority::High => "High", | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +#[derive(Clone, Debug, Default, PartialEq)] | |
| 75 | +pub struct StorageRule { | |
| 76 | + pub refused: Vec<Stock>, | |
| 77 | + pub priority: StoragePriority, | |
| 78 | +} | |
| 79 | + | |
| 80 | +impl StorageRule { | |
| 81 | + pub fn accepts(&self, kind: ItemKind) -> bool { | |
| 82 | + !self.refused.contains(&Stock::of(kind)) | |
| 83 | + } | |
| 84 | + | |
| 85 | + pub fn toggle(&mut self, stock: Stock) { | |
| 86 | + if self.refused.contains(&stock) { | |
| 87 | + self.refused.retain(|s| *s != stock); | |
| 88 | + } else { | |
| 89 | + self.refused.push(stock); | |
| 90 | + } | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +static DEFAULT_RULE: StorageRule = StorageRule { | |
| 95 | + refused: Vec::new(), | |
| 96 | + priority: StoragePriority::Normal, | |
| 97 | +}; | |
| 98 | + | |
| 40 | 99 | #[derive(Resource, Default, Debug, Clone, PartialEq)] |
| 41 | 100 | pub struct Zones { |
| 42 | 101 | stockpile: HashSet<UVec2>, |
| 43 | 102 | farm: HashSet<UVec2>, |
| 103 | + rules: HashMap<UVec2, StorageRule>, | |
| 44 | 104 | } |
| 45 | 105 | |
| 46 | 106 | impl Zones { |
| 107 | + pub fn rule(&self, tile: UVec2) -> &StorageRule { | |
| 108 | + self.rules.get(&tile).unwrap_or(&DEFAULT_RULE) | |
| 109 | + } | |
| 110 | + | |
| 111 | + pub fn accepts(&self, tile: UVec2, kind: ItemKind) -> bool { | |
| 112 | + self.is_stockpile(tile) && self.rule(tile).accepts(kind) | |
| 113 | + } | |
| 114 | + | |
| 115 | + pub fn stockpile_region(&self, start: UVec2) -> Vec<UVec2> { | |
| 116 | + if !self.is_stockpile(start) { | |
| 117 | + return Vec::new(); | |
| 118 | + } | |
| 119 | + let mut seen = HashSet::from([start]); | |
| 120 | + let mut stack = vec![start]; | |
| 121 | + while let Some(tile) = stack.pop() { | |
| 122 | + for step in [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] { | |
| 123 | + let next = tile.as_ivec2() + step; | |
| 124 | + if next.min_element() >= 0 | |
| 125 | + && self.is_stockpile(next.as_uvec2()) | |
| 126 | + && seen.insert(next.as_uvec2()) | |
| 127 | + { | |
| 128 | + stack.push(next.as_uvec2()); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + } | |
| 132 | + seen.into_iter().collect() | |
| 133 | + } | |
| 134 | + | |
| 135 | + pub fn update_region_rule(&mut self, start: UVec2, change: impl Fn(&mut StorageRule)) { | |
| 136 | + let mut rule = self.rule(start).clone(); | |
| 137 | + change(&mut rule); | |
| 138 | + for tile in self.stockpile_region(start) { | |
| 139 | + if rule == DEFAULT_RULE { | |
| 140 | + self.rules.remove(&tile); | |
| 141 | + } else { | |
| 142 | + self.rules.insert(tile, rule.clone()); | |
| 143 | + } | |
| 144 | + } | |
| 145 | + } | |
| 146 | + | |
| 47 | 147 | pub fn is_farm(&self, tile: UVec2) -> bool { |
| 48 | 148 | self.farm.contains(&tile) |
| 49 | 149 | } |
| @@ -80,6 +180,9 @@ impl Zones { | ||
| 80 | 180 | for tile in rect_tiles(a, b) { |
| 81 | 181 | if tile.x >= 0 && tile.y >= 0 { |
| 82 | 182 | set.remove(&tile.as_uvec2()); |
| 183 | + if kind == ZoneKind::Stockpile { | |
| 184 | + self.rules.remove(&tile.as_uvec2()); | |
| 185 | + } | |
| 83 | 186 | } |
| 84 | 187 | } |
| 85 | 188 | } |
| @@ -109,6 +212,7 @@ impl Zones { | ||
| 109 | 212 | |
| 110 | 213 | pub fn remove_tile(&mut self, tile: UVec2) { |
| 111 | 214 | self.stockpile.remove(&tile); |
| 215 | + self.rules.remove(&tile); | |
| 112 | 216 | } |
| 113 | 217 | |
| 114 | 218 | pub fn has_stockpile(&self) -> bool { |
| @@ -375,6 +479,26 @@ mod tests { | ||
| 375 | 479 | } |
| 376 | 480 | |
| 377 | 481 | #[test] |
| 482 | + fn storage_rules_apply_to_one_connected_stockpile() { | |
| 483 | + let map = WorldMap::filled(UVec2::new(10, 4), Terrain::Grass); | |
| 484 | + let mut zones = Zones::default(); | |
| 485 | + zones.add_stockpile(&map, IVec2::ZERO, IVec2::new(2, 1)); | |
| 486 | + zones.add_stockpile(&map, IVec2::new(6, 0), IVec2::new(8, 1)); | |
| 487 | + zones.update_region_rule(UVec2::ZERO, |rule| { | |
| 488 | + rule.toggle(Stock::Stone); | |
| 489 | + rule.priority = StoragePriority::High; | |
| 490 | + }); | |
| 491 | + assert!(!zones.accepts(UVec2::new(2, 1), ItemKind::Stone)); | |
| 492 | + assert!(zones.accepts(UVec2::new(2, 1), ItemKind::Wood)); | |
| 493 | + assert_eq!(zones.rule(UVec2::new(1, 0)).priority, StoragePriority::High); | |
| 494 | + assert!(zones.accepts(UVec2::new(7, 0), ItemKind::Stone)); | |
| 495 | + assert_eq!( | |
| 496 | + zones.rule(UVec2::new(7, 0)).priority, | |
| 497 | + StoragePriority::Normal | |
| 498 | + ); | |
| 499 | + } | |
| 500 | + | |
| 501 | + #[test] | |
| 378 | 502 | fn removing_clears_a_rectangle() { |
| 379 | 503 | let map = WorldMap::filled(UVec2::new(4, 4), Terrain::Grass); |
| 380 | 504 | let mut zones = Zones::default(); |
tasks.md +2 -2
| @@ -33,10 +33,10 @@ | ||
| 33 | 33 | - [x] Animals flee, ranged hunting, carcasses butchered into meat, taming with breeding herds and slaughter of spare tame animals |
| 34 | 34 | - [x] Mood (needs, meals, partner, home, sickness, grief), food poisoning from raw food, unhappy colonists leave, ageing and death of old age |
| 35 | 35 | - [x] Colonist panel: roster with mood faces (click to select) and per-colonist work type toggles |
| 36 | +- [x] Stockpile filters and priority per connected stockpile; stock target overrides in the panel | |
| 37 | +- [ ] Re-haul items from low to high priority stockpiles | |
| 36 | 38 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 37 | 39 | |
| 38 | 40 | ## Planned |
| 39 | -- [ ] Stockpile filters (which item kinds a zone accepts) and storage priority | |
| 40 | -- [ ] UI to view/override learned stock targets | |
| 41 | 41 | - [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony |
| 42 | 42 | - [ ] Smoother hill shading at close zoom (per-tile shading looks blocky) |