Move stored items up to higher priority stockpiles
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:38 UTC
- commit
- 37868d82453e0716af3982889226b7c17f3f4a9a
- parent
- 4ee3e12f13
- tree
- browse at this commit
4 files changed +78 -21
| CLAUDE.md | +1 -1 | |
| src/jobs/mod.rs | +44 -18 | |
| src/jobs/tests.rs | +32 -0 | |
| tasks.md | +1 -2 |
CLAUDE.md +1 -1
| @@ -44,7 +44,7 @@ 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 | -- 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. | |
| 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 as loose when their tile doesn't accept them or a higher-priority accepting tile with room exists (`storage_candidates`), so stock moves up the priorities and settles. `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals. | |
| 48 | 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. |
| 49 | 49 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 50 | 50 | |
src/jobs/mod.rs +44 -18
| @@ -456,6 +456,32 @@ pub fn find_work( | ||
| 456 | 456 | found |
| 457 | 457 | } |
| 458 | 458 | |
| 459 | +fn stacks_by_tile<'a>( | |
| 460 | + items: impl IntoIterator<Item = (&'a Item, &'a TilePos)>, | |
| 461 | +) -> HashMap<UVec2, (ItemKind, u32)> { | |
| 462 | + let mut stacks: HashMap<UVec2, (ItemKind, u32)> = HashMap::new(); | |
| 463 | + for (item, pos) in items { | |
| 464 | + stacks.entry(pos.0).or_insert((item.kind, 0)).1 += item.count; | |
| 465 | + } | |
| 466 | + stacks | |
| 467 | +} | |
| 468 | + | |
| 469 | +fn storage_candidates<'z>( | |
| 470 | + zones: &'z Zones, | |
| 471 | + reservations: &'z Reservations, | |
| 472 | + stacks: &'z HashMap<UVec2, (ItemKind, u32)>, | |
| 473 | + kind: ItemKind, | |
| 474 | +) -> impl Iterator<Item = UVec2> + 'z { | |
| 475 | + zones.stockpile_tiles().filter(move |&t| { | |
| 476 | + zones.accepts(t, kind) | |
| 477 | + && reservations.is_free(Target::Storage(t)) | |
| 478 | + && match stacks.get(&t) { | |
| 479 | + None => true, | |
| 480 | + Some(&(other, count)) => other == kind && count < STACK_LIMIT, | |
| 481 | + } | |
| 482 | + }) | |
| 483 | +} | |
| 484 | + | |
| 459 | 485 | pub fn find_storage<'a>( |
| 460 | 486 | map: &WorldMap, |
| 461 | 487 | zones: &Zones, |
| @@ -464,26 +490,12 @@ pub fn find_storage<'a>( | ||
| 464 | 490 | from: UVec2, |
| 465 | 491 | kind: ItemKind, |
| 466 | 492 | ) -> Option<UVec2> { |
| 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 | - }; | |
| 493 | + let stacks = stacks_by_tile(items); | |
| 476 | 494 | StoragePriority::DESCENDING |
| 477 | 495 | .into_iter() |
| 478 | 496 | .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 | - }) | |
| 497 | + let candidates: HashSet<UVec2> = storage_candidates(zones, reservations, &stacks, kind) | |
| 498 | + .filter(|&t| zones.rule(t).priority == priority) | |
| 487 | 499 | .collect(); |
| 488 | 500 | nearest_of(map, from, SEARCH_TILES, &candidates) |
| 489 | 501 | }) |
| @@ -496,10 +508,24 @@ pub fn find_haul( | ||
| 496 | 508 | items: &[(Item, TilePos)], |
| 497 | 509 | from: UVec2, |
| 498 | 510 | ) -> Option<Task> { |
| 511 | + let stacks = stacks_by_tile(items.iter().map(|(i, p)| (i, p))); | |
| 512 | + let mut best: HashMap<ItemKind, StoragePriority> = HashMap::new(); | |
| 513 | + let misplaced = |item: &Item, tile: UVec2, best: &mut HashMap<ItemKind, StoragePriority>| { | |
| 514 | + if !zones.accepts(tile, item.kind) { | |
| 515 | + return true; | |
| 516 | + } | |
| 517 | + let top = *best.entry(item.kind).or_insert_with(|| { | |
| 518 | + storage_candidates(zones, reservations, &stacks, item.kind) | |
| 519 | + .map(|t| zones.rule(t).priority) | |
| 520 | + .max() | |
| 521 | + .unwrap_or(StoragePriority::Low) | |
| 522 | + }); | |
| 523 | + zones.rule(tile).priority < top | |
| 524 | + }; | |
| 499 | 525 | let loose: HashSet<UVec2> = items |
| 500 | 526 | .iter() |
| 501 | 527 | .filter(|(item, pos)| { |
| 502 | - !zones.accepts(pos.0, item.kind) && reservations.is_free(Target::Pickup(pos.0)) | |
| 528 | + reservations.is_free(Target::Pickup(pos.0)) && misplaced(item, pos.0, &mut best) | |
| 503 | 529 | }) |
| 504 | 530 | .map(|(_, pos)| pos.0) |
| 505 | 531 | .collect(); |
src/jobs/tests.rs +32 -0
| @@ -1356,3 +1356,35 @@ fn hauled_items_go_to_the_highest_priority_stockpile_that_accepts_them() { | ||
| 1356 | 1356 | let in_high = |t: UVec2| t.x >= 15 && t.y >= 15; |
| 1357 | 1357 | assert!(placed.iter().all(|(_, t)| in_high(*t)), "{placed:?}"); |
| 1358 | 1358 | } |
| 1359 | + | |
| 1360 | +#[test] | |
| 1361 | +fn stored_items_move_up_to_a_higher_priority_stockpile() { | |
| 1362 | + let mut app = test_app(map_with_trees(&[])); | |
| 1363 | + set_goals(&mut app, &[]); | |
| 1364 | + put_beds(&mut app, 1); | |
| 1365 | + let map = app.world().resource::<WorldMap>().clone(); | |
| 1366 | + { | |
| 1367 | + let mut zones = app.world_mut().resource_mut::<Zones>(); | |
| 1368 | + zones.add_stockpile(&map, IVec2::new(3, 3), IVec2::new(4, 4)); | |
| 1369 | + zones.add_stockpile(&map, IVec2::new(14, 14), IVec2::new(15, 15)); | |
| 1370 | + } | |
| 1371 | + put_items(&mut app, UVec2::new(3, 3), ItemKind::Wood, 5); | |
| 1372 | + spawn_colonist(&mut app, UVec2::new(5, 5)); | |
| 1373 | + run_ticks(&mut app, 60 * 5); | |
| 1374 | + let wood_at = |app: &mut App| { | |
| 1375 | + let world = app.world_mut(); | |
| 1376 | + world | |
| 1377 | + .query::<(&Item, &TilePos)>() | |
| 1378 | + .iter(world) | |
| 1379 | + .map(|(_, p)| p.0) | |
| 1380 | + .collect::<Vec<_>>() | |
| 1381 | + }; | |
| 1382 | + assert_eq!(wood_at(&mut app), vec![UVec2::new(3, 3)]); | |
| 1383 | + app.world_mut() | |
| 1384 | + .resource_mut::<Zones>() | |
| 1385 | + .update_region_rule(UVec2::new(14, 14), |rule| { | |
| 1386 | + rule.priority = crate::zones::StoragePriority::High | |
| 1387 | + }); | |
| 1388 | + run_ticks(&mut app, 60 * 20); | |
| 1389 | + assert!(wood_at(&mut app).iter().all(|t| t.x >= 14 && t.y >= 14)); | |
| 1390 | +} | |
tasks.md +1 -2
| @@ -33,10 +33,9 @@ | ||
| 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 | |
| 36 | +- [x] Stockpile filters and priority per connected stockpile (stock moves up to higher priorities); stock target overrides in the panel | |
| 37 | 37 | - [x] Smooth hill shading overlay |
| 38 | 38 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 39 | 39 | |
| 40 | 40 | ## Planned |
| 41 | -- [ ] Re-haul items from low to high priority stockpiles | |
| 42 | 41 | - [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony |