Wolves give up on unreachable prey; cache warm spots
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 16:53 UTC
- commit
- fc106c9431b0273491eff60c683fcab317cea2e6
- parent
- 44a70b2cd5
- tree
- browse at this commit
6 files changed +95 -15
| CLAUDE.md | +2 -0 | |
| src/climate.rs | +3 -2 | |
| src/jobs/mod.rs | +27 -7 | |
| src/pathfinding.rs | +11 -1 | |
| src/threats.rs | +51 -5 | |
| tasks.md | +1 -0 |
CLAUDE.md +2 -0
| @@ -69,6 +69,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 69 | 69 | - plan_homes caches its reach flood by (`shape_version`, colony home, size); `WorldMap::set` bumps `shape_version` when walkability changes. `GatherMemo` also memoises forage (food mask) and haul failures (HAUL_RETRY_TICKS 120, reset when Zones change — checked before choose_jobs' early return). choose_jobs builds `occupied` lazily; run_jobs only collects mover positions while someone builds a wall. |
| 70 | 70 | - 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. Uncapped with hearths, that save settles at ~70–90 colonists (births vs old age at 60, growth food rule) at ~75 µs per tick, ~6 game days per 10 s. |
| 71 | 71 | - `ColonyReach::nearest(map, from, max_tiles, targets)`: when `from` is inside the colony reach, picks the nearest reachable target by distance (no flood); else falls back to `nearest_of`. Used by find_haul, find_storage (orders pass an empty reach), material_source, input_source. Cooking/toolmaking check stations before searching inputs; find_blueprint_work caches material lookups per call, has a reach precheck and a failure memo (reset when Blueprints change). Stock totals are one pass over items; the starving food-tile set is built lazily. |
| 72 | +- Wolves stalk with `find_path_adjacent_within(.., STALK_SEARCH 4000)` and on failure drop the target and rest GIVE_UP_TICKS (2 h; `Predator.resting_until`) — unreachable prey made each wolf flood the map every 30 ticks (95% of the frame on a walled-in colony). | |
| 73 | +- Warm spots: `warm_tiles` (fires in range only, LOS only when heat > 0) cached once per game hour in `GatherMemo.warm`, nearest via ColonyReach; it used to be rebuilt per cold colonist with an all-fires LOS check per tile (winter spikes). run_jobs builds its colonist position map only while someone tends a patient. | |
| 72 | 74 | - `ColonyLog::push` also logs at debug level; run benchmarks with `RUST_LOG=info,simulator=debug` to see colony events. |
| 73 | 75 | - Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time). |
| 74 | 76 | - Searches and A* share `can_step` (no corner cutting): a target the BFS could reach diagonally but A* couldn't made A* exhaust the map (~10 ms) many times a second on a 50-day save. Mined yields drop on the miner's tile when the site is only diagonally reachable. |
src/climate.rs +3 -2
| @@ -151,8 +151,9 @@ pub fn tile_temperature( | ||
| 151 | 151 | }; |
| 152 | 152 | base + fires |
| 153 | 153 | .into_iter() |
| 154 | - .filter(|&(pos, _)| line_of_sight(map, pos, tile)) | |
| 155 | - .map(|(pos, kind)| heat_at(kind, pos, tile)) | |
| 154 | + .map(|(pos, kind)| (pos, heat_at(kind, pos, tile))) | |
| 155 | + .filter(|&(pos, heat)| heat > 0.0 && line_of_sight(map, pos, tile)) | |
| 156 | + .map(|(_, heat)| heat) | |
| 156 | 157 | .sum::<f32>() |
| 157 | 158 | } |
| 158 | 159 | |
src/jobs/mod.rs +27 -7
| @@ -572,6 +572,7 @@ pub struct GatherMemo { | ||
| 572 | 572 | haul: Memo, |
| 573 | 573 | build: Memo, |
| 574 | 574 | food: HashMap<Entity, u64>, |
| 575 | + warm: Option<(u64, HashSet<UVec2>)>, | |
| 575 | 576 | } |
| 576 | 577 | |
| 577 | 578 | pub fn find_work( |
| @@ -1163,12 +1164,11 @@ pub fn chilly_soon(tick: SimTick) -> bool { | ||
| 1163 | 1164 | outdoor_temperature(tick) < COLD_TEMP || outdoor_temperature(later) < COLD_TEMP |
| 1164 | 1165 | } |
| 1165 | 1166 | |
| 1166 | -pub fn find_warm_spot( | |
| 1167 | +pub fn warm_tiles( | |
| 1167 | 1168 | map: &WorldMap, |
| 1168 | 1169 | structures: &[(UVec2, StructureKind, u32)], |
| 1169 | 1170 | outdoor: f32, |
| 1170 | - from: UVec2, | |
| 1171 | -) -> Option<UVec2> { | |
| 1171 | +) -> HashSet<UVec2> { | |
| 1172 | 1172 | let fires: Vec<(UVec2, StructureKind)> = structures |
| 1173 | 1173 | .iter() |
| 1174 | 1174 | .filter(|(_, kind, _)| kind.heat() > 0.0) |
| @@ -1184,9 +1184,15 @@ pub fn find_warm_spot( | ||
| 1184 | 1184 | .filter(|&p| map.in_bounds(p) && map.move_cost(p).is_some()) |
| 1185 | 1185 | .map(|p| p.as_uvec2()) |
| 1186 | 1186 | .filter(|p| !taken.contains(p)) |
| 1187 | - .filter(|&p| tile_temperature(map, outdoor, p, fires.iter().copied()) >= WARM_TEMP) | |
| 1187 | + .filter(|&p| { | |
| 1188 | + let near = fires | |
| 1189 | + .iter() | |
| 1190 | + .copied() | |
| 1191 | + .filter(|(pos, kind)| pos.as_vec2().distance(p.as_vec2()) < kind.radius()); | |
| 1192 | + tile_temperature(map, outdoor, p, near) >= WARM_TEMP | |
| 1193 | + }) | |
| 1188 | 1194 | .collect(); |
| 1189 | - nearest_of(map, from, SEARCH_TILES, &warm) | |
| 1195 | + warm | |
| 1190 | 1196 | } |
| 1191 | 1197 | |
| 1192 | 1198 | pub fn find_refuel(ctx: &BuildContext, from: UVec2) -> Option<Task> { |
| @@ -1540,7 +1546,14 @@ fn choose_jobs( | ||
| 1540 | 1546 | }) |
| 1541 | 1547 | } else if let Some(spot) = warmth |
| 1542 | 1548 | .cold() |
| 1543 | - .then(|| find_warm_spot(&map, &structures_list, outdoor, tile.0)) | |
| 1549 | + .then(|| { | |
| 1550 | + let hour = tick.0 / crate::sim::TICKS_PER_HOUR; | |
| 1551 | + if gather_memo.warm.as_ref().is_none_or(|(at, _)| *at != hour) { | |
| 1552 | + gather_memo.warm = Some((hour, warm_tiles(&map, &structures_list, outdoor))); | |
| 1553 | + } | |
| 1554 | + let warm = &gather_memo.warm.as_ref().expect("computed above").1; | |
| 1555 | + reach.nearest(&map, tile.0, SEARCH_TILES, warm) | |
| 1556 | + }) | |
| 1544 | 1557 | .flatten() |
| 1545 | 1558 | .filter(|&spot| spot != tile.0) |
| 1546 | 1559 | { |
| @@ -1845,7 +1858,14 @@ fn run_jobs( | ||
| 1845 | 1858 | ) { |
| 1846 | 1859 | let (mut changed, mut ate) = messages; |
| 1847 | 1860 | let hour = hour_of_day(*tick); |
| 1848 | - let positions: HashMap<Entity, UVec2> = colonists.iter().map(|c| (c.0, c.1.0)).collect(); | |
| 1861 | + let tending = colonists | |
| 1862 | + .iter() | |
| 1863 | + .any(|c| c.3.0.is_some_and(|j| matches!(j.task, Task::Tend { .. }))); | |
| 1864 | + let positions: HashMap<Entity, UVec2> = colonists | |
| 1865 | + .iter() | |
| 1866 | + .filter(|_| tending) | |
| 1867 | + .map(|c| (c.0, c.1.0)) | |
| 1868 | + .collect(); | |
| 1849 | 1869 | let walling = colonists.iter().any(|c| { |
| 1850 | 1870 | c.3.0 |
| 1851 | 1871 | .is_some_and(|j| matches!(j.task, Task::Build { kind, .. } if kind.blocks_tile())) |
src/pathfinding.rs +11 -1
| @@ -29,12 +29,22 @@ pub fn find_path(map: &WorldMap, start: UVec2, goal: UVec2) -> Option<Vec<UVec2> | ||
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | pub fn find_path_adjacent(map: &WorldMap, start: UVec2, target: UVec2) -> Option<Vec<UVec2>> { |
| 32 | + find_path_adjacent_within(map, start, target, usize::MAX) | |
| 33 | +} | |
| 34 | + | |
| 35 | +pub fn find_path_adjacent_within( | |
| 36 | + map: &WorldMap, | |
| 37 | + start: UVec2, | |
| 38 | + target: UVec2, | |
| 39 | + max_expanded: usize, | |
| 40 | +) -> Option<Vec<UVec2>> { | |
| 32 | 41 | let target = target.as_ivec2(); |
| 33 | - astar( | |
| 42 | + astar_limited( | |
| 34 | 43 | map, |
| 35 | 44 | start, |
| 36 | 45 | |p| is_adjacent(p, target), |
| 37 | 46 | |p| octile(p, target).saturating_sub(DIAGONAL * MIN_TERRAIN_COST), |
| 47 | + max_expanded, | |
| 38 | 48 | ) |
| 39 | 49 | } |
| 40 | 50 | |
src/threats.rs +51 -5
| @@ -9,7 +9,7 @@ use crate::{ | ||
| 9 | 9 | jobs::{CurrentJob, Reservations, Target}, |
| 10 | 10 | mood::Losses, |
| 11 | 11 | needs::is_sleeping, |
| 12 | - pathfinding::{find_path_adjacent, is_adjacent}, | |
| 12 | + pathfinding::{find_path_adjacent_within, is_adjacent}, | |
| 13 | 13 | population::ColonyLog, |
| 14 | 14 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | 15 | structures::{Structure, StructureKind}, |
| @@ -31,6 +31,8 @@ impl Plugin for ThreatsPlugin { | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | pub const PROWL_RANGE: i32 = 24; |
| 34 | +const STALK_SEARCH: usize = 4000; | |
| 35 | +const GIVE_UP_TICKS: u64 = 2 * TICKS_PER_HOUR; | |
| 34 | 36 | pub const COLONIST_DETOUR: i32 = 6; |
| 35 | 37 | pub const COLONIST_BITE: f32 = 0.25; |
| 36 | 38 | pub const ANIMAL_BITE: f32 = 0.5; |
| @@ -75,6 +77,7 @@ pub struct Predator { | ||
| 75 | 77 | pub target: Option<Entity>, |
| 76 | 78 | pub fed_until: u64, |
| 77 | 79 | pub cooldown: u32, |
| 80 | + pub resting_until: u64, | |
| 78 | 81 | } |
| 79 | 82 | |
| 80 | 83 | #[derive(Clone, Copy, Debug, PartialEq)] |
| @@ -120,7 +123,7 @@ fn prowl( | ||
| 120 | 123 | })) |
| 121 | 124 | .collect(); |
| 122 | 125 | for (tile, mut path, mut predator) in &mut wolves { |
| 123 | - if tick.0 < predator.fed_until { | |
| 126 | + if tick.0 < predator.fed_until || tick.0 < predator.resting_until { | |
| 124 | 127 | predator.target = None; |
| 125 | 128 | continue; |
| 126 | 129 | } |
| @@ -133,9 +136,15 @@ fn prowl( | ||
| 133 | 136 | if is_adjacent(end.as_ivec2(), target.tile.as_ivec2()) || end == target.tile { |
| 134 | 137 | continue; |
| 135 | 138 | } |
| 136 | - if let Some(route) = find_path_adjacent(&map, tile.0, target.tile) { | |
| 137 | - let entering = path.0.front().copied(); | |
| 138 | - path.0 = entering.into_iter().chain(route).collect(); | |
| 139 | + match find_path_adjacent_within(&map, tile.0, target.tile, STALK_SEARCH) { | |
| 140 | + Some(route) => { | |
| 141 | + let entering = path.0.front().copied(); | |
| 142 | + path.0 = entering.into_iter().chain(route).collect(); | |
| 143 | + } | |
| 144 | + None => { | |
| 145 | + predator.target = None; | |
| 146 | + predator.resting_until = tick.0 + GIVE_UP_TICKS; | |
| 147 | + } | |
| 139 | 148 | } |
| 140 | 149 | } |
| 141 | 150 | } |
| @@ -328,4 +337,41 @@ mod tests { | ||
| 328 | 337 | let predator = app.world().get::<Predator>(wolf).unwrap(); |
| 329 | 338 | assert!(predator.fed_until > 0 && predator.target.is_none()); |
| 330 | 339 | } |
| 340 | + | |
| 341 | + // Prey a wolf can't reach (behind walls, across water) made it flood the whole map every 30 | |
| 342 | + // ticks; with a few wolves and a walled-in colony that was 95% of the frame. A wolf now gives | |
| 343 | + // up on unreachable prey and rests before trying again. | |
| 344 | + #[test] | |
| 345 | + fn wolves_give_up_on_unreachable_prey() { | |
| 346 | + use crate::{animals::AnimalKind, world::Terrain}; | |
| 347 | + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass); | |
| 348 | + for d in -1..=1 { | |
| 349 | + for (x, y) in [(d, -1), (d, 1), (-1, d), (1, d)] { | |
| 350 | + map.set_wall(UVec2::new((15 + x) as u32, (15 + y) as u32), true); | |
| 351 | + } | |
| 352 | + } | |
| 353 | + let mut app = App::new(); | |
| 354 | + app.insert_resource(map) | |
| 355 | + .init_resource::<SimTick>() | |
| 356 | + .add_systems(Update, prowl); | |
| 357 | + let wolf = app | |
| 358 | + .world_mut() | |
| 359 | + .spawn(( | |
| 360 | + Animal { | |
| 361 | + kind: AnimalKind::Wolf, | |
| 362 | + }, | |
| 363 | + TilePos(UVec2::new(5, 5)), | |
| 364 | + Predator::default(), | |
| 365 | + )) | |
| 366 | + .id(); | |
| 367 | + app.world_mut().spawn(( | |
| 368 | + Animal { | |
| 369 | + kind: AnimalKind::Rabbit, | |
| 370 | + }, | |
| 371 | + TilePos(UVec2::splat(15)), | |
| 372 | + )); | |
| 373 | + app.update(); | |
| 374 | + let predator = app.world().get::<Predator>(wolf).unwrap(); | |
| 375 | + assert!(predator.target.is_none() && predator.resting_until > 0); | |
| 376 | + } | |
| 331 | 377 | } |
tasks.md +1 -0
| @@ -55,6 +55,7 @@ | ||
| 55 | 55 | - [x] Dynamic rooms: detected from walls/doors (any build order), roofed when lived in, homes share walls and only plan missing ones, any rectangular room with 2 beds can become a home, boxed-in doors removed; room quality (space, floor, roof, statue, hearth, crowding) drives mood and shows on hover |
| 56 | 56 | - [x] Variable home sizes (solo room, couple, more space per child); solo colonists and grown children get their own room; families expand homes by moving a wall out |
| 57 | 57 | - [x] Colonists keep busy paths (paved or heavily worn) free when planning buildings |
| 58 | +- [x] Optimization: wolves give up on unreachable prey, cached warm spots, cheaper heat checks | |
| 58 | 59 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 59 | 60 | |
| 60 | 61 | ## Planned |