Keep busy paths free when colonists plan buildings

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 16:15 UTC
commit
44a70b2cd57a05a8236b1b370ec4a9efef819ce7
parent
91e856af5e
tree
browse at this commit

6 files changed +37 -1

CLAUDE.md +1 -1

@@ -39,7 +39,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
39 39 - `src/animals.rs` `Animal{kind: AnimalKind::{Deer, Rabbit}}` entities (Path/TilePos/SimPos/MoveSpeed, moved by colonist `follow_path`; ellipse-body visuals, z=0.9). `populate` on WorldGenerated (population per kind, ≥25 tiles from colony), `migrate` refills one per kind every 2 game hours, `wander` idles 2–8 s then walks ≤10 tiles; an animal reserved as `Target::Animal` (being hunted) stands still. meat 30/8, hunt_work 360/160.
40 40 - Hunting/animals (2nd pass): wild animals flee (`flee_goal`, FLEE_RANGE 4, run FLEE_DISTANCE) from colonists, abandoning paths that end near a threat; hunters shoot from ≤ HUNT_RANGE 6 with `line_of_sight` (repath when the path end drifts > HUNT_RANGE/2 from prey); kills drop `ItemKind::Carcass` (meat count, RawFood stock group, spoils like meat, not edible); `find_butchering` (before food gathering) → `Task::Butcher{site}` converts the carcass stack into Meat in place after BUTCHER_WORK. `Tame` animals: `Task::Tame` (adjacent, kind.tame_work; target stands still while being tamed) via `find_taming` when meat isn't wanted and < MAX_TAME 6; tame animals don't flee, wander within PEN_RADIUS of home, `breed` daily (≥2 of a kind, 50%), excluded from migration counts. `find_hunt` (`Wildlife` = (entity, tile, kind, tame)) takes spare tame animals (herd > 2) before wild ones.
41 41 - `src/farming.rs` `plan_farm` (every 600 ticks, once a stockpile exists and no farm): `FarmContext::plan` picks the FARM_SIZE² square near home (BFS corners) maximising soil − distance where every tile is farmable, plant-free and ≥ FARM_CLEARANCE from stockpile, blueprints, structures and items.
42 -- `src/paths.rs` traffic wear. `follow_path` adds 1 wear when a colonist (not an animal) enters a tile. `update_paths` (hourly): wear × WEAR_KEPT_PER_HOUR (0.98); Grass/Soil/Sand with wear ≥ PATH_WEAR (8), no plant, not farm → `Terrain::Path`; Path with wear < REGROW_WEAR → Grass. `plan_paving` (hourly) keeps up to PAVE_BATCH `BuildKind::Pave` blueprints on the busiest Path tiles with wear ≥ PAVE_WEAR (20); built with 1 stone → `Terrain::Paved`. Path cost 80, Paved 60 (= CHEAPEST_MOVE_COST, which A*'s heuristic uses; a test enforces no terrain is cheaper), so colonists prefer and reinforce them. Hover shows wear. Tuned from observed traffic of 3 colonists: busiest tiles reach ~15–30 wear by day 3–4.
42 +- `src/paths.rs` traffic wear. `follow_path` adds 1 wear when a colonist (not an animal) enters a tile. `update_paths` (hourly): wear × WEAR_KEPT_PER_HOUR (0.98); Grass/Soil/Sand with wear ≥ PATH_WEAR (8), no plant, not farm → `Terrain::Path`; Path with wear < REGROW_WEAR → Grass. `plan_paving` (hourly) keeps up to PAVE_BATCH `BuildKind::Pave` blueprints on the busiest Path tiles with wear ≥ PAVE_WEAR (20); built with 1 stone → `Terrain::Paved`. `busy_path` (Paved or wear ≥ BUSY_WEAR = PAVE_WEAR): colonists don't plan homes/expansions (`HomeContext::open`), shelter walls or BuildContext sites (fires, beds, workbench) there; manual placement is unaffected. Path cost 80, Paved 60 (= CHEAPEST_MOVE_COST, which A*'s heuristic uses; a test enforces no terrain is cheaper), so colonists prefer and reinforce them. Hover shows wear. Tuned from observed traffic of 3 colonists: busiest tiles reach ~15–30 wear by day 3–4.
43 43 - `src/economy.rs` learned stock targets. `Stock::{Wood, Stone, Iron, Gold, RawFood, Meals}` groups item kinds (raw foods are one group). `ColonyGoals` keeps `use_per_day` per stock (EMA, ~24 h memory; seeded with guesses), `record_use(kind, n)` is called by jobs for real consumption (eating, cooking input, build material — not spoilage), `learn_needs` (hourly) updates rates and goals = clamp(rate × buffer days (2 materials, 1.5 food), min, 300) + material for pending blueprints (`planned_needs`). `wanted(kind, stocks, on_the_way)` compares the whole group. Stock counts only ground items `in_colony` (Chebyshev ≤ `COLONY_RADIUS` 60 of home; colonist searches cover it, tested) plus carried ones — far stray stacks used to satisfy goals while unreachable (jobs, info panel and arrivals all use this). `ColonyGoals::fixed` for tests.
44 44 - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (MAX_COLONISTS = usize::MAX, i.e. uncapped; stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, `free_name_index`; `colonist_name` numbers repeats past the 26 NAMES, e.g. "Ada 2") appears at an `edge_tile` with an unordered MoveTo home.
45 45 - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), hearth at `Home::hearth_tile` (hi−1), statue + `BuildKind::Floor` on unfloored interior tiles (`floored`; doesn't overwrite other blueprints) for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). Households (`Household{partners, children}` per family; `adults()` = max(partners,1)) size homes via `desired_interior` (solo 3×2, couple 4×3, +1 child 5×3, 2+ children 5×4). Couples get a vacant home (preferring ones big enough) or `plan_sized`; otherwise one solo adult per pass (no Partner, not Child, family None / parents' family / no home) gets a new `Family` (Commands) and a vacant home ≤ 4×3 or a planned solo room. Owned built homes smaller than desired get `HomeContext::expansion`: grow one side (width first, then height) where the new ring may reuse walls and the new strip is free; opened old wall/door tiles get Deconstruct, missing new ring tiles Wall; Homes entry updated at once. Owned built homes get Bed blueprints up to `adults()` at `bed_tiles_for`. New homes via `HomeContext::plan_sized`: BFS from colony home (center ≥ HOME_MIN_DISTANCE), first HOME_CANDIDATES fits, pick the one sharing most walls (`shared_walls`: ring tiles may be existing walls or planned wall blueprints, else open and not in a room; interior open and not in a room; some door site must open onto walkable, non-room ground). `Home::blueprints(map, toward)` plans only missing ring walls, door via `door_site` (non-wall, walkable outward), beds. Adoption: any rectangular room (`home_of_room`, interior ≥ MIN_HOME_INTERIOR 3×2) with ≥2 beds not already a home → vacant. `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes (owned or vacant). Selection text shows partner / child days left.

src/building.rs +2 -0

@@ -8,6 +8,7 @@ use crate::{
8 8 colonist::Colonist,
9 9 family::Homes,
10 10 items::{Item, ItemKind},
11 + paths::busy_path,
11 12 rooms::Rooms,
12 13 selection::{Cursor, update_cursor},
13 14 sim::{SimSystems, SimTick},
@@ -168,6 +169,7 @@ impl ShelterContext<'_> {
168 169
169 170 fn wall_buildable(&self, tile: IVec2) -> bool {
170 171 can_zone(self.map, tile)
172 + && !busy_path(self.map, tile)
171 173 && self.map.plant(tile).is_none()
172 174 && !self.items.contains(&tile.as_uvec2())
173 175 && !self

src/family.rs +26 -0

@@ -9,6 +9,7 @@ use crate::{
9 9 economy::in_colony,
10 10 items::{Item, ItemAssets},
11 11 pathfinding::{ColonyReach, Reach, nearest_tile},
12 + paths::busy_path,
12 13 population::{ColonyLog, GROWTH_FOOD_DAYS, MAX_COLONISTS, food_in_store},
13 14 rng::SimRng,
14 15 rooms::{Room, Rooms},
@@ -365,6 +366,7 @@ impl HomeContext<'_> {
365 366 fn open(&self, tile: IVec2) -> bool {
366 367 self.map.in_bounds(tile)
367 368 && can_zone(self.map, tile)
369 + && !busy_path(self.map, tile)
368 370 && self.map.plant(tile).is_none()
369 371 && !self.map.is_wall(tile)
370 372 && !self.occupied.contains(&tile.as_uvec2())
@@ -1356,4 +1358,28 @@ mod tests {
1356 1358 .count();
1357 1359 assert_eq!(walls, 5);
1358 1360 }
1361 +
1362 + // Colonists keep their busiest routes open: no home (walls or floor) goes on a paved road or
1363 + // a heavily worn path, so the traffic that wore it in can keep flowing.
1364 + #[test]
1365 + fn homes_avoid_busy_paths() {
1366 + let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Stone);
1367 + for y in 0..40 {
1368 + for x in 22..36 {
1369 + map.set(UVec2::new(x, y), Terrain::Grass);
1370 + }
1371 + map.set(UVec2::new(28, y), Terrain::Paved);
1372 + map.set_wear(UVec2::new(27, y), crate::paths::BUSY_WEAR);
1373 + }
1374 + let ctx = HomeContext {
1375 + rooms: &Rooms::detect(&map, &HashSet::new()),
1376 + map: &map,
1377 + zones: &Zones::default(),
1378 + blueprints: &Blueprints::default(),
1379 + homes: &Homes::default(),
1380 + occupied: &HashSet::new(),
1381 + };
1382 + let home = ctx.plan(UVec2::new(24, 20)).unwrap();
1383 + assert!(home.lo.x > 28, "{home:?}");
1384 + }
1359 1385 }

src/jobs/mod.rs +2 -0

@@ -30,6 +30,7 @@ use crate::{
30 30 ColonyReach, WANDER_SEARCH, can_step, find_nearby_path, find_path, find_path_adjacent,
31 31 is_adjacent, nearest_of, nearest_tile, search_reach,
32 32 },
33 + paths::busy_path,
33 34 rng::SimRng,
34 35 sim::{SimSystems, SimTick, hour_of_day},
35 36 skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed},
@@ -848,6 +849,7 @@ pub struct BuildContext<'a> {
848 849 impl BuildContext<'_> {
849 850 fn buildable(&self, tile: UVec2) -> bool {
850 851 can_zone(self.map, tile.as_ivec2())
852 + && !busy_path(self.map, tile.as_ivec2())
851 853 && !self.zones.is_stockpile(tile)
852 854 && !self.zones.is_farm(tile)
853 855 && self.map.plant(tile.as_ivec2()).is_none()

src/paths.rs +5 -0

@@ -25,6 +25,11 @@ impl Plugin for PathsPlugin {
25 25
26 26 pub const PATH_WEAR: f32 = 8.0;
27 27 pub const PAVE_WEAR: f32 = 20.0;
28 +pub const BUSY_WEAR: f32 = PAVE_WEAR;
29 +
30 +pub fn busy_path(map: &WorldMap, tile: IVec2) -> bool {
31 + map.get(tile) == Some(Terrain::Paved) || map.wear(tile) >= BUSY_WEAR
32 +}
28 33 pub const REGROW_WEAR: f32 = 1.0;
29 34 pub const WEAR_KEPT_PER_HOUR: f32 = 0.98;
30 35 pub const PAVE_BATCH: usize = 8;

tasks.md +1 -0

@@ -54,6 +54,7 @@
54 54 - [x] Medicine: healing herbs, Medicine skill and Doctor work; doctors tend the wounded (faster healing), the badly wounded rest in bed
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 +- [x] Colonists keep busy paths (paved or heavily worn) free when planning buildings
57 58 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
58 59
59 60 ## Planned