Add stockpile filters, storage priority and stock target overrides

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

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

@@ -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)