Fix colonists getting trapped inside newly built walls

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 19:59 UTC
commit
d98ebfd088528fad6f2f76b5cc2fedd389b0ec07
parent
a71d21b923
tree
browse at this commit

5 files changed +110 -5

CLAUDE.md +1 -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 × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; 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, material and food stock/goal lines, 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)`, 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. 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. Paths exclude start, include goal.
24 24 - `src/colonist.rs` `Colonist{name}` (spawned/respawned 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(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10, berries/meat/vegetables 30, meals 10); `wanted(kind, stock, on_the_way)`. `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`) → `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. 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}` (`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.

src/colonist.rs +37 -0

@@ -102,6 +102,10 @@ pub fn follow_path(
102 102 let Some(&next) = path.0.front() else {
103 103 continue;
104 104 };
105 + if map.move_cost(next.as_ivec2()).is_none() {
106 + path.0.clear();
107 + continue;
108 + }
105 109 let cost = map
106 110 .step_cost(tile.0.as_ivec2(), next.as_ivec2())
107 111 .unwrap_or(100) as f32
@@ -167,6 +171,39 @@ mod tests {
167 171 }
168 172
169 173 #[test]
174 + fn movers_stop_instead_of_walking_into_a_new_wall() {
175 + let mut map = WorldMap::filled(UVec2::new(10, 10), Terrain::Grass);
176 + map.set_wall(UVec2::new(3, 1), true);
177 + let mut app = App::new();
178 + app.insert_resource(map)
179 + .init_resource::<Time>()
180 + .add_systems(Update, follow_path);
181 + let mover = app
182 + .world_mut()
183 + .spawn((
184 + TilePos(UVec2::new(1, 1)),
185 + Path(VecDeque::from([
186 + UVec2::new(2, 1),
187 + UVec2::new(3, 1),
188 + UVec2::new(4, 1),
189 + ])),
190 + sim_pos(WorldMap::tile_center(UVec2::new(1, 1))),
191 + ))
192 + .id();
193 + for _ in 0..40 {
194 + app.world_mut()
195 + .resource_mut::<Time>()
196 + .advance_by(Duration::from_millis(50));
197 + app.update();
198 + }
199 + assert_eq!(
200 + app.world().get::<TilePos>(mover),
201 + Some(&TilePos(UVec2::new(2, 1)))
202 + );
203 + assert!(app.world().get::<Path>(mover).unwrap().0.is_empty());
204 + }
205 +
206 + #[test]
170 207 fn colonists_wear_down_the_ground_they_walk_on() {
171 208 let mut app = App::new();
172 209 app.insert_resource(WorldMap::filled(UVec2::new(10, 10), Terrain::Grass))

src/jobs/mod.rs +12 -3

@@ -1018,7 +1018,7 @@ fn run_jobs(
1018 1018 mut blueprints: ResMut<Blueprints>,
1019 1019 mut items: ItemQuery,
1020 1020 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
1021 - animals: Query<&TilePos, (With<Animal>, Without<Colonist>)>,
1021 + animals: Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>,
1022 1022 mut changed: MessageWriter<TileChanged>,
1023 1023 mut colonists: Query<
1024 1024 (
@@ -1035,7 +1035,16 @@ fn run_jobs(
1035 1035 >,
1036 1036 ) {
1037 1037 let hour = hour_of_day(*tick);
1038 - let occupied: Vec<UVec2> = colonists.iter().map(|c| c.1.0).collect();
1038 + let occupied: Vec<UVec2> = colonists
1039 + .iter()
1040 + .flat_map(|c| [Some(c.1.0), c.2.0.front().copied()])
1041 + .chain(
1042 + animals
1043 + .iter()
1044 + .flat_map(|(t, p)| [Some(t.0), p.0.front().copied()]),
1045 + )
1046 + .flatten()
1047 + .collect();
1039 1048 for (entity, tile, mut path, mut current, mut idle, mut carrying, rest, mut hunger) in
1040 1049 &mut colonists
1041 1050 {
@@ -1118,7 +1127,7 @@ fn run_jobs(
1118 1127 kind,
1119 1128 progress,
1120 1129 } => match animals.get(*animal) {
1121 - Ok(prey) if reservations.holder(Target::Animal(*animal)) == Some(entity) => {
1130 + Ok((prey, _)) if reservations.holder(Target::Animal(*animal)) == Some(entity) => {
1122 1131 let prey = prey.0;
1123 1132 if prey != tile.0 && !is_adjacent(tile.0.as_ivec2(), prey.as_ivec2()) {
1124 1133 match find_path_adjacent(&map, tile.0, prey) {

src/jobs/tests.rs +47 -0

@@ -1,3 +1,5 @@
1 +use std::collections::VecDeque;
2 +
1 3 use super::*;
2 4 use crate::{
3 5 animals::{Animal, AnimalKind},
@@ -1016,3 +1018,48 @@ fn colonists_pave_planned_paths_with_stone() {
1016 1018 );
1017 1019 assert_eq!(items_of(&mut app, ItemKind::Stone), 4);
1018 1020 }
1021 +
1022 +// The wall-builder must not trap a colonist who is just stepping onto the tile: that colonist's
1023 +// TilePos is still the previous tile, so only checking TilePos would let the wall close on them.
1024 +#[test]
1025 +fn walls_wait_for_colonists_stepping_onto_the_tile() {
1026 + let mut app = test_app(map_with_trees(&[]));
1027 + set_goals(&mut app, &[]);
1028 + put_beds(&mut app, 2);
1029 + put_items(&mut app, UVec2::new(2, 2), ItemKind::Stone, 10);
1030 + let wall = UVec2::new(8, 8);
1031 + app.world_mut()
1032 + .resource_mut::<Blueprints>()
1033 + .0
1034 + .insert(wall, BuildKind::Structure(StructureKind::Wall));
1035 + let colonists = [
1036 + spawn_colonist(&mut app, UVec2::new(4, 4)),
1037 + spawn_colonist(&mut app, UVec2::new(8, 10)),
1038 + ];
1039 + let mut sent = false;
1040 + for _ in 0..60 * 20 {
1041 + let nearly_built = |c| {
1042 + matches!(
1043 + task(&app, c),
1044 + Some(Task::Build { progress, kind, .. }) if progress + 4 >= kind.work()
1045 + )
1046 + };
1047 + if !sent && let Some(&builder) = colonists.iter().find(|&&c| nearly_built(c)) {
1048 + let walker = *colonists.iter().find(|&&c| c != builder).unwrap();
1049 + let from = app.world().get::<TilePos>(walker).unwrap().0;
1050 + let route: VecDeque<UVec2> = find_path(app.world().resource::<WorldMap>(), from, wall)
1051 + .unwrap()
1052 + .into();
1053 + app.world_mut().get_mut::<Path>(walker).unwrap().0 = route;
1054 + sent = true;
1055 + }
1056 + run_ticks(&mut app, 1);
1057 + let map = app.world().resource::<WorldMap>();
1058 + for &c in &colonists {
1059 + let tile = app.world().get::<TilePos>(c).unwrap().0;
1060 + assert!(!map.is_wall(tile.as_ivec2()), "colonist trapped in a wall");
1061 + }
1062 + }
1063 + assert!(sent);
1064 + assert!(app.world().resource::<WorldMap>().is_wall(wall.as_ivec2()));
1065 +}

src/pathfinding.rs +13 -1

@@ -74,7 +74,9 @@ fn astar(
74 74 heuristic: impl Fn(IVec2) -> u32,
75 75 ) -> Option<Vec<UVec2>> {
76 76 let start = start.as_ivec2();
77 - map.move_cost(start)?;
77 + if !map.in_bounds(start) {
78 + return None;
79 + }
78 80 if is_goal(start) {
79 81 return Some(vec![]);
80 82 }
@@ -270,6 +272,16 @@ mod tests {
270 272 assert!(path.iter().filter(|p| p.y == 1).count() >= 6);
271 273 }
272 274
275 + // A wall can go up on a tile a colonist is standing on (or just about to enter); routes must
276 + // still start from that tile so nobody gets permanently stuck inside a wall.
277 + #[test]
278 + fn colonists_can_walk_out_of_a_blocked_tile() {
279 + let mut map = map_from(&["....."]);
280 + map.set_wall(UVec2::new(2, 0), true);
281 + let path = find_path(&map, UVec2::new(2, 0), UVec2::new(4, 0)).unwrap();
282 + assert_eq!(path, vec![UVec2::new(3, 0), UVec2::new(4, 0)]);
283 + }
284 +
273 285 #[test]
274 286 fn crosses_slow_terrain_when_it_is_the_only_way() {
275 287 let map = map_from(&["..~.."]);