Wolves give up on unreachable prey; cache warm spots

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

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

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