Stop picking targets that paths cannot reach

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 07:48 UTC
commit
ff1183082eb72a114a44f5b5f35f0f95e9570c06
parent
4ab42bfca2
tree
browse at this commit

5 files changed +47 -10

.gitignore +1 -0

@@ -1 +1,2 @@
1 1 /target
2 +simulator-save.json

CLAUDE.md +3 -1

@@ -20,7 +20,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
20 20 - `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Chunk layers (`Layer::Terrain` z=0, `Layer::Zones` z=0.3, `Layer::Plants` z=0.5 (tileset per `PlantLook`: tree, bush, ripe bush, sprout, young crop, ripe crop), `Layer::Roofs` z=0.85 translucent striped overlay, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × SHADE_FLAT × height brightness; hill shading is a separate `HillShade` mesh over the whole map (z 0.1) with a one-texel-per-tile black-alpha texture (`shadow_alpha(hillshade)`, light from NW, `RELIEF`), linearly sampled so slopes look smooth, water transparent, rebuilt on WorldGenerated; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection).
21 21 - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect.
22 22 - `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, colonist count, material and food stock-group/learned-goal lines, recent colony events, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile/Build (`progress_fraction`). Clock shows day/dawn/dusk/night; hover shows structures with remaining fuel, plant growth, farm, animals. `describe_colonist(&ColonistView)` includes rest and food. Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
23 -- `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)` (start may itself be blocked so movers can leave a tile that became a wall; `follow_path` also drops a route whose next tile became impassable), 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles (bitmap visited set). `nearest_of(start, max_tiles, &HashSet targets)` = nearest_tile over a precollected target set, returning None at once when no target is within `search_reach(max_tiles)` Chebyshev — use it instead of predicates that scan all items per visited tile (that was the dominant per-tick cost). Paths exclude start, include goal.
23 +- `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)` (start may itself be blocked so movers can leave a tile that became a wall; `follow_path` also drops a route whose next tile became impassable), 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles (bitmap visited set, same `can_step` rule as A*). `nearest_of(start, max_tiles, &HashSet targets)` = nearest_tile over a precollected target set, returning None at once when no target is within `search_reach(max_tiles)` Chebyshev — use it instead of predicates that scan all items per visited tile (that was the dominant per-tick cost). Paths exclude start, include goal.
24 24 - `src/colonist.rs` `Colonist{name}` (`spawn_colonist(index)` picks name/colour; 3 spawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front.
25 25 - `src/jobs/mod.rs` job system (tests in `src/jobs/tests.rs`). `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals` (economy.rs) = learned demand per stock group. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted/starving ones skip the idle timer), priority: sleep if exhausted → eat if hungry (drops carried load) → sleep if `wants_sleep` → deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`BuildContext` (shared by lighting and beds; `buildable` = dry, not stockpile/plant/structure/planned/item, unreserved): campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → cooking if meals wanted and a raw stack ≥ COOK_INPUT exists (`find_cooking` at a campfire with ≥ COOK_MIN_FUEL, else `find_campfire`) → if food wanted: `find_work` restricted to food yields (harvest/forage) then `find_sowing` → `find_bed_site` → `find_blueprint_work` → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work, hunts and cooks in progress) → `find_hunt` if meat wanted → `find_sowing` → `find_haul` → wander. `find_bed_site`: one bed per colonist incl. planned; next to nearest bed, else campfire, else home; ≥ BED_STOCKPILE_CLEARANCE from stockpile and farm tiles so a wall ring fits. `find_blueprint_work`: nearest unreserved blueprint with material available (checks the reached tile and impassable neighbours, so built walls can be deconstructed). Cook: fetch COOK_INPUT raw units of one kind, cook COOK_WORK next to the campfire (`Target::Station`), drop 1 Meal (0.9 nutrition vs 0.6 raw). Orders: animal → ordered Hunt (checked first), work site → Work, loose item → Haul, else MoveTo. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work).
26 26 - `src/items.rs` `Item{kind: ItemKind::{Wood, Stone, Iron, Gold, Berries, Meat, Vegetables, Meal}, count, spoil}` (`Item::new`, `merge` averages spoil by count, `with_count`; `drop_items` takes a whole Item) (`nutrition`, `is_food`, `is_raw_food`) + `TilePos` entities (Mesh2d rect, z=0.7), one stack per tile, `STACK_LIMIT`=50; `ItemQuery` alias; `drop_items` stacks, `take_items`, `room_for(tile, kind)` (0 if other kind present), `stock()`. `Carrying(Option<Item>)` on colonists, shown via hidden/visible `CarriedVisual` child. Items cleared on WorldGenerated.
@@ -54,6 +54,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
54 54 ## Performance notes
55 55 - Native profiling: temporarily add a Startup system writing `Action::SetSpeed(SimSpeed::Max)` in sim.rs, `CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release`, run, `sample <pid> 15 -file out.txt`, aggregate `simulator::` symbols (revert the hack).
56 56 - Hot spots fixed so far: predicate BFS scanning items per tile (use `nearest_of` with precollected sets), searches that cannot match (pre-check wanted kinds, empty farm sets), choose_jobs setup skipped unless someone is ready (idle timer ticked in a pre-pass), periodic passes over only `WorldMap::worn_tiles` / `growing_plants`, light-driven tilemap re-tints rate-limited to 100 ms, `BuildContext.occupied` set.
57 +- 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.
58 +- Profiling a saved colony: copy the browser save to `simulator-save.json` (gitignored; the native build reads it with F9), temporarily add a Startup system writing `Action::Load` + `SetSpeed(Max)`. `sample` sometimes captures only a handful of samples on the first try — rerun.
57 59 - Browser at Max: ~100–350× on a 12-colonist colony, dips at dusk (ambient steps re-tint all chunks) and on hourly ticks.
58 60
59 61 ## Gotchas

src/jobs/mod.rs +8 -2

@@ -26,7 +26,7 @@ use crate::{
26 26 },
27 27 mood::{Ate, Sick},
28 28 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
29 - pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile},
29 + pathfinding::{can_step, find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile},
30 30 rng::SimRng,
31 31 sim::{SimSystems, SimTick, hour_of_day},
32 32 structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
@@ -1798,12 +1798,18 @@ fn run_jobs(
1798 1798 } else {
1799 1799 kind.complete(&mut map, site);
1800 1800 changed.write(TileChanged(site));
1801 + let step = site.as_ivec2() - tile.0.as_ivec2();
1802 + let drop_at = if can_step(&map, tile.0.as_ivec2(), step) {
1803 + site
1804 + } else {
1805 + tile.0
1806 + };
1801 1807 if let Some(work) = work {
1802 1808 drop_items(
1803 1809 &mut commands,
1804 1810 &mut items,
1805 1811 &item_assets,
1806 - site,
1812 + drop_at,
1807 1813 Item::new(work.item, work.count),
1808 1814 );
1809 1815 }

src/pathfinding.rs +34 -7

@@ -42,6 +42,15 @@ pub fn is_adjacent(a: IVec2, b: IVec2) -> bool {
42 42 a != b && (a - b).abs().max_element() == 1
43 43 }
44 44
45 +pub fn can_step(map: &WorldMap, from: IVec2, step: IVec2) -> bool {
46 + let next = from + step;
47 + map.move_cost(next).is_some()
48 + && (step.x == 0
49 + || step.y == 0
50 + || (map.move_cost(from + IVec2::new(step.x, 0)).is_some()
51 + && map.move_cost(from + IVec2::new(0, step.y)).is_some()))
52 +}
53 +
45 54 pub fn nearest_tile(
46 55 map: &WorldMap,
47 56 start: UVec2,
@@ -64,7 +73,7 @@ pub fn nearest_tile(
64 73 }
65 74 for step in NEIGHBORS {
66 75 let next = pos.as_ivec2() + step;
67 - if map.move_cost(next).is_some() && !seen[index(next.as_uvec2())] {
76 + if can_step(map, pos.as_ivec2(), step) && !seen[index(next.as_uvec2())] {
68 77 seen[index(next.as_uvec2())] = true;
69 78 seen_count += 1;
70 79 queue.push_back(next.as_uvec2());
@@ -124,16 +133,13 @@ fn astar(
124 133 let current_cost = cost_so_far[index(current)];
125 134 for step in NEIGHBORS {
126 135 let next = current + step;
136 + if !can_step(map, current, step) {
137 + continue;
138 + }
127 139 let Some(terrain_cost) = map.step_cost(current, next) else {
128 140 continue;
129 141 };
130 142 let diagonal = step.x != 0 && step.y != 0;
131 - if diagonal
132 - && (map.move_cost(current + IVec2::new(step.x, 0)).is_none()
133 - || map.move_cost(current + IVec2::new(0, step.y)).is_none())
134 - {
135 - continue;
136 - }
137 143 let base = if diagonal { DIAGONAL } else { STRAIGHT };
138 144 let new_cost = current_cost + base * terrain_cost;
139 145 if new_cost < cost_so_far[index(next)] {
@@ -176,6 +182,27 @@ mod tests {
176 182 use super::*;
177 183 use crate::world::Terrain;
178 184
185 + // Target searches must use the same no-corner-cutting rule as A*. A stack on a tile touching
186 + // the colony only diagonally between two rocks was "found" by the search, A* then explored
187 + // the whole map before failing, and the job was picked again at once: ~10 ms, many times a
188 + // second, which made Max speed crawl on a 50-day colony.
189 + #[test]
190 + fn searches_never_pick_targets_that_paths_cannot_reach() {
191 + let mut map = WorldMap::filled(UVec2::splat(6), Terrain::Grass);
192 + for y in 3..6 {
193 + for x in 0..6 {
194 + map.set(UVec2::new(x, y), Terrain::Stone);
195 + }
196 + }
197 + map.set(UVec2::new(2, 3), Terrain::Grass);
198 + map.set(UVec2::new(3, 4), Terrain::Gravel);
199 + let pocket = UVec2::new(3, 4);
200 + assert!(map.move_cost(IVec2::new(2, 3)).is_some());
201 + assert_eq!(find_path(&map, UVec2::ZERO, pocket), None);
202 + let targets = HashSet::from([pocket]);
203 + assert_eq!(nearest_of(&map, UVec2::ZERO, 100, &targets), None);
204 + }
205 +
179 206 // Idle colonists look for food and cooking input several times per game minute. A stack far
180 207 // beyond what the flood fill can cover made every one of those searches visit the full tile
181 208 // budget and fail, which dominated frame time at high speeds.

tasks.md +1 -0

@@ -40,6 +40,7 @@
40 40 - [x] Cold: colonist warmth (roofs insulate, fires heat); cold drains food, slows rest, lowers mood; cold colonists warm up by fires; campfires built when chilly, not only dark; food and wood targets cover the cold days ahead
41 41 - [x] Workshops: workbench, iron tools (crafted on demand, equipped, +50% work speed, wear out); gold statues in finished homes lift mood; Craft work type
42 42 - [x] Save/load (F5/F9, toolbar buttons): browser localStorage on web, simulator-save.json natively
43 +- [x] Max speed fix: target searches respect A*'s no-corner-cutting rule (unreachable diagonal stacks caused repeated full-map path searches)
43 44 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
44 45
45 46 ## Planned