Run fixed schedules single-threaded; cache reach; cap wander searches

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 12:59 UTC
commit
4acafd45bec176a9aff2ab9e064551786d979f8f
parent
bd659d1db1
tree
browse at this commit

6 files changed +242 -83

src/animals.rs +4 -4

@@ -6,7 +6,7 @@ use bevy::prelude::*;
6 6 use crate::{
7 7 colonist::{Colonist, MoveSpeed, Path},
8 8 jobs::{CurrentJob, Task},
9 - pathfinding::find_path,
9 + pathfinding::{WANDER_SEARCH, find_nearby_path},
10 10 rng::SimRng,
11 11 sim::{SimPos, SimSystems, SimTick, TICKS_PER_HOUR, sim_pos},
12 12 threats::{Health, Predator},
@@ -391,7 +391,7 @@ pub fn wander(
391 391 rng.range_i32(-PEN_RADIUS..PEN_RADIUS + 1),
392 392 );
393 393 if map.in_bounds(goal)
394 - && let Some(route) = find_path(&map, tile.0, goal.as_uvec2())
394 + && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
395 395 {
396 396 path.0 = route.into();
397 397 idle.0
@@ -407,7 +407,7 @@ pub fn wander(
407 407 .is_none_or(|end| flee_goal(*end, threats).is_some());
408 408 if heading_into_danger && let Some(goal) = flee_goal(tile.0, threats) {
409 409 let goal = goal.clamp(IVec2::ZERO, map.size.as_ivec2() - 1).as_uvec2();
410 - if let Some(route) = find_path(&map, tile.0, goal) {
410 + if let Some(route) = find_nearby_path(&map, tile.0, goal, WANDER_SEARCH) {
411 411 path.0 = route.into();
412 412 continue;
413 413 }
@@ -421,7 +421,7 @@ pub fn wander(
421 421 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
422 422 );
423 423 if map.in_bounds(goal)
424 - && let Some(route) = find_path(&map, tile.0, goal.as_uvec2())
424 + && let Some(route) = find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
425 425 {
426 426 path.0 = route.into();
427 427 let secs = rng.range_f32(IDLE_SECS);

src/family.rs +11 -6

@@ -438,6 +438,7 @@ fn plan_homes(
438 438 items: Query<&TilePos, With<Item>>,
439 439 families: Query<(Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>,
440 440 mut colony_reach: ResMut<ColonyReach>,
441 + mut flood: Local<Option<((u64, UVec2, UVec2), Reach)>>,
441 442 ) {
442 443 if !tick.0.is_multiple_of(HOME_PLAN_INTERVAL_TICKS) {
443 444 return;
@@ -495,12 +496,16 @@ fn plan_homes(
495 496 let home = homes.vacant.swap_remove(index);
496 497 homes.by_family.insert(family, home);
497 498 }
498 - let reachable = Reach::flood(&map, colony_home);
499 - colony_reach.0 = map
500 - .move_cost(colony_home.as_ivec2())
501 - .is_some()
502 - .then(|| reachable.clone());
503 499 let anchored = map.move_cost(colony_home.as_ivec2()).is_some();
500 + let key = (map.shape_version(), colony_home, map.size);
501 + let stale = flood.as_ref().is_none_or(|(cached, _)| *cached != key)
502 + || (anchored && colony_reach.0.is_none());
503 + if stale {
504 + let fresh = Reach::flood(&map, colony_home);
505 + colony_reach.0 = anchored.then(|| fresh.clone());
506 + *flood = Some((key, fresh));
507 + }
508 + let reachable = &flood.as_ref().expect("flood computed above").1;
504 509 blueprints
505 510 .0
506 511 .retain(|tile, _| !anchored || reachable.near(*tile));
@@ -514,7 +519,7 @@ fn plan_homes(
514 519 .chain(doors.iter().copied())
515 520 .collect();
516 521 for home in homes.all() {
517 - if let Some((tile, kind)) = door_repair(&map, &entries, &reachable, home, colony_home) {
522 + if let Some((tile, kind)) = door_repair(&map, &entries, reachable, home, colony_home) {
518 523 blueprints.0.insert(tile, kind);
519 524 continue;
520 525 }

src/jobs/mod.rs +58 -18

@@ -27,7 +27,8 @@ use crate::{
27 27 mood::{Ate, Sick},
28 28 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
29 29 pathfinding::{
30 - ColonyReach, can_step, find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile,
30 + ColonyReach, WANDER_SEARCH, can_step, find_nearby_path, find_path, find_path_adjacent,
31 + is_adjacent, nearest_of, nearest_tile,
31 32 },
32 33 rng::SimRng,
33 34 sim::{SimSystems, SimTick, hour_of_day},
@@ -519,13 +520,27 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
519 520 }
520 521
521 522 const GATHER_RETRY_TICKS: u64 = 300;
523 +const HAUL_RETRY_TICKS: u64 = 120;
522 524
523 -#[derive(Default)]
524 -pub struct GatherMemo {
525 +#[derive(Default, Clone, Copy)]
526 +pub struct Memo {
525 527 mask: u32,
526 528 until: u64,
527 529 }
528 530
531 +impl Memo {
532 + fn skips(self, mask: u32, tick: u64) -> bool {
533 + self.mask == mask && tick < self.until
534 + }
535 +}
536 +
537 +#[derive(Default)]
538 +pub struct GatherMemo {
539 + gather: Memo,
540 + forage: Memo,
541 + haul: Memo,
542 +}
543 +
529 544 pub fn find_work(
530 545 map: &WorldMap,
531 546 reservations: &Reservations,
@@ -1213,6 +1228,9 @@ fn choose_jobs(
1213 1228 mut gather_memo: Local<GatherMemo>,
1214 1229 ) {
1215 1230 let (homes, designations, reach) = plans;
1231 + if zones.is_changed() {
1232 + gather_memo.haul = Memo::default();
1233 + }
1216 1234 let wolves: Vec<(Entity, UVec2)> = animals
1217 1235 .iter()
1218 1236 .filter(|(_, _, animal, _)| animal.kind.is_predator())
@@ -1462,8 +1480,17 @@ fn choose_jobs(
1462 1480 .iter()
1463 1481 .filter(|&&k| wanted(k))
1464 1482 .fold(0u32, |mask, k| mask | 1 << k.index());
1465 - let skip_gather = gather_memo.mask == gather_mask && tick.0 < gather_memo.until;
1483 + let food_mask = gather_mask
1484 + & ItemKind::ALL
1485 + .iter()
1486 + .filter(|k| k.is_food())
1487 + .fold(0u32, |mask, k| mask | 1 << k.index());
1488 + let skip_gather = gather_memo.gather.skips(gather_mask, tick.0);
1489 + let skip_forage = gather_memo.forage.skips(food_mask, tick.0);
1490 + let skip_haul = gather_memo.haul.skips(0, tick.0);
1466 1491 let gather_failed = std::cell::Cell::new(false);
1492 + let forage_failed = std::cell::Cell::new(false);
1493 + let haul_failed = std::cell::Cell::new(false);
1467 1494 let candidates: [(WorkType, Option<Skill>, &dyn Fn() -> Option<Task>); 16] = [
1468 1495 (WorkType::Haul, None, &|| {
1469 1496 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
@@ -1479,14 +1506,14 @@ fn choose_jobs(
1479 1506 find_butchering(&map, &reservations, &snapshot, tile.0)
1480 1507 }),
1481 1508 (WorkType::Food, None, &|| {
1482 - ItemKind::ALL
1483 - .iter()
1484 - .any(|&k| k.is_food() && wanted(k))
1485 - .then(|| {
1486 - let food = |_, k: ItemKind| k.is_food() && wanted(k);
1487 - find_work_within(&map, &reservations, tile.0, FORAGE_SEARCH_TILES, food)
1488 - })
1489 - .flatten()
1509 + if food_mask == 0 || skip_forage {
1510 + return None;
1511 + }
1512 + let food = |_, k: ItemKind| k.is_food() && wanted(k);
1513 + let work =
1514 + find_work_within(&map, &reservations, tile.0, FORAGE_SEARCH_TILES, food);
1515 + forage_failed.set(work.is_none());
1516 + work
1490 1517 }),
1491 1518 (WorkType::Gather, None, &designated),
1492 1519 (WorkType::Food, None, &|| {
@@ -1525,7 +1552,12 @@ fn choose_jobs(
1525 1552 .flatten()
1526 1553 }),
1527 1554 (WorkType::Haul, None, &|| {
1528 - find_haul(&map, &zones, &reservations, &reach, &snapshot, tile.0)
1555 + if skip_haul {
1556 + return None;
1557 + }
1558 + let haul = find_haul(&map, &zones, &reservations, &reach, &snapshot, tile.0);
1559 + haul_failed.set(haul.is_none());
1560 + haul
1529 1561 }),
1530 1562 ];
1531 1563 let poor = |skill: &Option<Skill>| skill.is_some_and(|s| skills.level(s) < POOR_LEVEL);
@@ -1537,11 +1569,18 @@ fn choose_jobs(
1537 1569 .filter(|(_, skill, _)| !poor(skill))
1538 1570 .chain(allowed.filter(|(_, skill, _)| poor(skill)))
1539 1571 .find_map(|(.., find)| find());
1572 + let retry = |mask, ticks| Memo {
1573 + mask,
1574 + until: tick.0 + ticks,
1575 + };
1540 1576 if gather_failed.get() {
1541 - *gather_memo = GatherMemo {
1542 - mask: gather_mask,
1543 - until: tick.0 + GATHER_RETRY_TICKS,
1544 - };
1577 + gather_memo.gather = retry(gather_mask, GATHER_RETRY_TICKS);
1578 + }
1579 + if forage_failed.get() {
1580 + gather_memo.forage = retry(food_mask, GATHER_RETRY_TICKS);
1581 + }
1582 + if haul_failed.get() {
1583 + gather_memo.haul = retry(0, HAUL_RETRY_TICKS);
1545 1584 }
1546 1585 task
1547 1586 };
@@ -1579,7 +1618,8 @@ fn choose_jobs(
1579 1618 rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1),
1580 1619 );
1581 1620 if map.in_bounds(goal)
1582 - && let Some(wander_path) = find_path(&map, tile.0, goal.as_uvec2())
1621 + && let Some(wander_path) =
1622 + find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH)
1583 1623 {
1584 1624 path.0 = wander_path.into();
1585 1625 idle.restart(rng.range_f32(IDLE_SECS));

src/pathfinding.rs +119 -54

@@ -160,12 +160,41 @@ pub fn nearest_of(
160 160 nearest_tile(map, start, max_tiles, |p| targets.contains(&p))
161 161 }
162 162
163 +fn octile(a: IVec2, b: IVec2) -> u32 {
164 + let d = (a - b).abs();
165 + let (min, max) = (d.x.min(d.y) as u32, d.x.max(d.y) as u32);
166 + (DIAGONAL * min + STRAIGHT * (max - min)) * MIN_TERRAIN_COST
167 +}
168 +
169 +#[derive(Default)]
170 +struct Scratch {
171 + generation: u32,
172 + stamp: Vec<u32>,
173 + cost: Vec<u32>,
174 + from: Vec<IVec2>,
175 + open: BinaryHeap<Reverse<(u32, i32, i32)>>,
176 +}
177 +
178 +thread_local! {
179 + static SCRATCH: std::cell::RefCell<Scratch> = std::cell::RefCell::new(Scratch::default());
180 +}
181 +
163 182 fn astar(
164 183 map: &WorldMap,
165 184 start: UVec2,
166 185 is_goal: impl Fn(IVec2) -> bool,
167 186 heuristic: impl Fn(IVec2) -> u32,
168 187 ) -> Option<Vec<UVec2>> {
188 + astar_limited(map, start, is_goal, heuristic, usize::MAX)
189 +}
190 +
191 +fn astar_limited(
192 + map: &WorldMap,
193 + start: UVec2,
194 + is_goal: impl Fn(IVec2) -> bool,
195 + heuristic: impl Fn(IVec2) -> u32,
196 + max_expanded: usize,
197 +) -> Option<Vec<UVec2>> {
169 198 let start = start.as_ivec2();
170 199 if !map.in_bounds(start) {
171 200 return None;
@@ -173,67 +202,89 @@ fn astar(
173 202 if is_goal(start) {
174 203 return Some(vec![]);
175 204 }
176 -
177 - let width = map.size.x as usize;
178 - let index = |p: IVec2| p.y as usize * width + p.x as usize;
179 - let tiles = map.size.element_product() as usize;
180 - let mut cost_so_far = vec![u32::MAX; tiles];
181 - let mut came_from = vec![None::<IVec2>; tiles];
182 - let mut open = BinaryHeap::new();
183 -
184 - cost_so_far[index(start)] = 0;
185 - open.push(Reverse((heuristic(start), start.x, start.y)));
186 -
187 - while let Some(Reverse((_, x, y))) = open.pop() {
188 - let current = IVec2::new(x, y);
189 - if is_goal(current) {
190 - return Some(reconstruct(&came_from, index, start, current));
205 + SCRATCH.with_borrow_mut(|scratch| {
206 + let tiles = map.size.element_product() as usize;
207 + if scratch.stamp.len() != tiles {
208 + *scratch = Scratch {
209 + stamp: vec![0; tiles],
210 + cost: vec![0; tiles],
211 + from: vec![IVec2::ZERO; tiles],
212 + ..default()
213 + };
191 214 }
192 - let current_cost = cost_so_far[index(current)];
193 - for step in NEIGHBORS {
194 - let next = current + step;
195 - if !can_step(map, current, step) {
196 - continue;
215 + scratch.generation = scratch.generation.wrapping_add(1);
216 + if scratch.generation == 0 {
217 + scratch.stamp.fill(0);
218 + scratch.generation = 1;
219 + }
220 + let Scratch {
221 + generation,
222 + stamp,
223 + cost,
224 + from,
225 + open,
226 + } = scratch;
227 + let generation = *generation;
228 + let width = map.size.x as usize;
229 + let index = |p: IVec2| p.y as usize * width + p.x as usize;
230 + open.clear();
231 + stamp[index(start)] = generation;
232 + cost[index(start)] = 0;
233 + from[index(start)] = start;
234 + open.push(Reverse((heuristic(start), start.x, start.y)));
235 + let mut expanded = 0;
236 + while let Some(Reverse((_, x, y))) = open.pop() {
237 + let current = IVec2::new(x, y);
238 + if is_goal(current) {
239 + let mut path = vec![current.as_uvec2()];
240 + let mut at = current;
241 + while from[index(at)] != start {
242 + at = from[index(at)];
243 + path.push(at.as_uvec2());
244 + }
245 + path.reverse();
246 + return Some(path);
197 247 }
198 - let Some(terrain_cost) = map.step_cost(current, next) else {
199 - continue;
200 - };
201 - let diagonal = step.x != 0 && step.y != 0;
202 - let base = if diagonal { DIAGONAL } else { STRAIGHT };
203 - let new_cost = current_cost + base * terrain_cost;
204 - if new_cost < cost_so_far[index(next)] {
205 - cost_so_far[index(next)] = new_cost;
206 - came_from[index(next)] = Some(current);
207 - open.push(Reverse((new_cost + heuristic(next), next.x, next.y)));
248 + expanded += 1;
249 + if expanded > max_expanded {
250 + return None;
251 + }
252 + let current_cost = cost[index(current)];
253 + for step in NEIGHBORS {
254 + let next = current + step;
255 + if !can_step(map, current, step) {
256 + continue;
257 + }
258 + let Some(terrain_cost) = map.step_cost(current, next) else {
259 + continue;
260 + };
261 + let diagonal = step.x != 0 && step.y != 0;
262 + let base = if diagonal { DIAGONAL } else { STRAIGHT };
263 + let new_cost = current_cost + base * terrain_cost;
264 + let i = index(next);
265 + if stamp[i] != generation || new_cost < cost[i] {
266 + stamp[i] = generation;
267 + cost[i] = new_cost;
268 + from[i] = current;
269 + open.push(Reverse((new_cost + heuristic(next), next.x, next.y)));
270 + }
208 271 }
209 272 }
210 - }
211 - None
273 + None
274 + })
212 275 }
213 276
214 -fn octile(a: IVec2, b: IVec2) -> u32 {
215 - let d = (a - b).abs();
216 - let (min, max) = (d.x.min(d.y) as u32, d.x.max(d.y) as u32);
217 - (DIAGONAL * min + STRAIGHT * (max - min)) * MIN_TERRAIN_COST
218 -}
277 +pub const WANDER_SEARCH: usize = 800;
219 278
220 -fn reconstruct(
221 - came_from: &[Option<IVec2>],
222 - index: impl Fn(IVec2) -> usize,
223 - start: IVec2,
224 - goal: IVec2,
225 -) -> Vec<UVec2> {
226 - let mut path = vec![goal.as_uvec2()];
227 - let mut current = goal;
228 - while let Some(prev) = came_from[index(current)] {
229 - if prev == start {
230 - break;
231 - }
232 - path.push(prev.as_uvec2());
233 - current = prev;
234 - }
235 - path.reverse();
236 - path
279 +pub fn find_nearby_path(
280 + map: &WorldMap,
281 + start: UVec2,
282 + goal: UVec2,
283 + max_expanded: usize,
284 +) -> Option<Vec<UVec2>> {
285 + map.move_cost(goal.as_ivec2())?;
286 + let goal = goal.as_ivec2();
287 + astar_limited(map, start, |p| p == goal, |p| octile(p, goal), max_expanded)
237 288 }
238 289
239 290 #[cfg(test)]
@@ -241,6 +292,20 @@ mod tests {
241 292 use super::*;
242 293 use crate::world::Terrain;
243 294
295 + // Wandering picks random nearby goals; one walled off by a river or cliff made A* search the
296 + // whole connected region before failing. Short trips cap the search instead.
297 + #[test]
298 + fn nearby_paths_give_up_early_on_unreachable_goals() {
299 + let mut map = WorldMap::filled(UVec2::splat(100), Terrain::Grass);
300 + for y in 0..100 {
301 + map.set(UVec2::new(50, y), Terrain::DeepWater);
302 + }
303 + let start = UVec2::new(45, 50);
304 + assert!(find_nearby_path(&map, start, UVec2::new(40, 52), 200).is_some());
305 + assert_eq!(find_nearby_path(&map, start, UVec2::new(55, 50), 200), None);
306 + assert_eq!(find_path(&map, start, UVec2::new(55, 50)), None);
307 + }
308 +
244 309 // Target searches must use the same no-corner-cutting rule as A*. A stack on a tile touching
245 310 // the colony only diagonally between two rocks was "found" by the search, A* then explored
246 311 // the whole map before failing, and the job was picked again at once: ~10 ms, many times a

src/sim.rs +47 -1

@@ -1,6 +1,11 @@
1 1 use std::time::Duration;
2 2
3 -use bevy::{app::RunFixedMainLoopSystems, platform::time::Instant, prelude::*};
3 +use bevy::{
4 + app::RunFixedMainLoopSystems,
5 + ecs::schedule::{ScheduleLabel, SingleThreadedExecutor},
6 + platform::time::Instant,
7 + prelude::*,
8 +};
4 9
5 10 use crate::actions::Action;
6 11
@@ -13,6 +18,17 @@ pub struct SimPlugin;
13 18
14 19 impl Plugin for SimPlugin {
15 20 fn build(&self, app: &mut App) {
21 + for label in [
22 + FixedFirst.intern(),
23 + FixedPreUpdate.intern(),
24 + FixedUpdate.intern(),
25 + FixedPostUpdate.intern(),
26 + FixedLast.intern(),
27 + ] {
28 + app.edit_schedule(label, |schedule| {
29 + schedule.set_executor(SingleThreadedExecutor::new());
30 + });
31 + }
16 32 app.insert_resource(Time::<Fixed>::from_hz(TICKS_PER_SECOND))
17 33 .init_resource::<SimTick>()
18 34 .init_resource::<SimSpeed>()
@@ -31,9 +47,39 @@ impl Plugin for SimPlugin {
31 47 .in_set(RunFixedMainLoopSystems::AfterFixedMainLoop),
32 48 ),
33 49 );
50 + #[cfg(not(target_arch = "wasm32"))]
51 + if std::env::var_os("SIM_BENCH").is_some() {
52 + app.add_systems(Startup, start_bench)
53 + .add_systems(Update, report_bench);
54 + }
55 + }
56 +}
57 +
58 +#[cfg(not(target_arch = "wasm32"))]
59 +fn start_bench(mut actions: MessageWriter<Action>) {
60 + actions.write(Action::Load);
61 + actions.write(Action::SetSpeed(SimSpeed::Max));
62 +}
63 +
64 +#[cfg(not(target_arch = "wasm32"))]
65 +fn report_bench(tick: Res<SimTick>, real: Res<Time<Real>>, mut last: Local<Option<(f32, u64)>>) {
66 + let now = real.elapsed_secs();
67 + if now < BENCH_WARMUP_SECS {
68 + return;
69 + }
70 + let (since, from) = *last.get_or_insert((now, tick.0));
71 + if now - since >= BENCH_REPORT_SECS {
72 + let days = (tick.0 - from) as f32 / (HOURS_PER_DAY * TICKS_PER_HOUR) as f32;
73 + info!("bench: {days:.2} game days per {BENCH_REPORT_SECS} s");
74 + *last = Some((now, tick.0));
34 75 }
35 76 }
36 77
78 +#[cfg(not(target_arch = "wasm32"))]
79 +const BENCH_REPORT_SECS: f32 = 10.0;
80 +#[cfg(not(target_arch = "wasm32"))]
81 +const BENCH_WARMUP_SECS: f32 = 5.0;
82 +
37 83 #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
38 84 pub enum SimSystems {
39 85 Decide,

src/world.rs +3 -0

@@ -197,6 +197,9 @@ impl WorldMap {
197 197
198 198 pub fn set(&mut self, pos: UVec2, terrain: Terrain) {
199 199 let i = self.index(pos);
200 + if self.tiles[i].move_cost().is_some() != terrain.move_cost().is_some() {
201 + self.shape_version += 1;
202 + }
200 203 self.tiles[i] = terrain;
201 204 }
202 205