Reuse vacant homes, repair blocked doors, remove spare beds

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 08:16 UTC
commit
2f2f76394a4ac0b9b26f3c84b684661d52e5ef03
parent
ff1183082e
tree
browse at this commit

4 files changed +338 -29

CLAUDE.md +1 -1

@@ -42,7 +42,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
42 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.
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, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home.
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): drops homes of extinct families; roofs finished homes (`roofs_needed`: interior enclosed exactly); plans one home per partnered family without one via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt free of stockpile/farm/blueprints/homes) → wall/door/bed blueprints (door nearest colony home). `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. Selection text shows partner / child days left.
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`), statue for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). New homes via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt unclaimed AND walkable — homes used to be packed wall to wall with doors facing a neighbour's wall) → wall/door/bed blueprints (door nearest colony home). `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.
46 46 - `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick.
47 47 - Storage rules (zones.rs): `Zones.rules` per stockpile tile `StorageRule{refused: Vec<Stock>, priority: StoragePriority::{Low, Normal, High}}` (absent = default), `accepts(tile, kind)`, `update_region_rule` applies to the 4-connected stockpile region; removing tiles drops rules. `find_storage` tries priorities High→Normal→Low, nearest accepting free tile with room; `find_haul` treats items as loose when their tile doesn't accept them or a higher-priority accepting tile with room exists (`storage_candidates`), so stock moves up the priorities and settles. `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals.
48 48 - `src/panel.rs` top-right colonist panel (also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities.

src/building.rs +1 -3

@@ -272,9 +272,7 @@ pub fn plan_shelter(
272 272 let beds: Vec<UVec2> = structures
273 273 .iter()
274 274 .filter(|(pos, kind)| {
275 - *kind == StructureKind::Bed
276 - && !map.is_roofed(pos.as_ivec2())
277 - && homes.owner(*pos).is_none()
275 + *kind == StructureKind::Bed && !map.is_roofed(pos.as_ivec2()) && !homes.is_home(*pos)
278 276 })
279 277 .map(|(pos, _)| *pos)
280 278 .collect();

src/family.rs +335 -25

@@ -69,7 +69,7 @@ impl Child {
69 69 }
70 70 }
71 71
72 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
72 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
73 73 pub struct Home {
74 74 pub lo: UVec2,
75 75 pub hi: UVec2,
@@ -104,17 +104,36 @@ impl Home {
104 104 [corner, corner + UVec2::X]
105 105 }
106 106
107 + pub fn outward(&self, tile: UVec2) -> IVec2 {
108 + let t = tile.as_ivec2();
109 + let step = if tile.y == self.lo.y {
110 + IVec2::NEG_Y
111 + } else if tile.y == self.hi.y {
112 + IVec2::Y
113 + } else if tile.x == self.lo.x {
114 + IVec2::NEG_X
115 + } else {
116 + IVec2::X
117 + };
118 + t + step
119 + }
120 +
121 + fn is_corner(&self, t: UVec2) -> bool {
122 + (t.x == self.lo.x || t.x == self.hi.x) && (t.y == self.lo.y || t.y == self.hi.y)
123 + }
124 +
125 + pub fn door_sites(&self) -> impl Iterator<Item = UVec2> + use<> {
126 + let home = *self;
127 + self.ring().filter(move |t| !home.is_corner(*t))
128 + }
129 +
107 130 pub fn statue_tile(&self) -> UVec2 {
108 131 UVec2::new(self.hi.x - 1, self.lo.y + 1)
109 132 }
110 133
111 134 pub fn blueprints(&self, toward: UVec2) -> HashMap<UVec2, BuildKind> {
112 - let corner = |t: &UVec2| {
113 - (t.x == self.lo.x || t.x == self.hi.x) && (t.y == self.lo.y || t.y == self.hi.y)
114 - };
115 135 let door = self
116 - .ring()
117 - .filter(|t| !corner(t))
136 + .door_sites()
118 137 .min_by(|a, b| {
119 138 a.as_vec2()
120 139 .distance_squared(toward.as_vec2())
@@ -138,6 +157,8 @@ impl Home {
138 157 pub struct Homes {
139 158 pub by_family: HashMap<u32, Home>,
140 159 next_family: u32,
160 + #[serde(default)]
161 + pub vacant: Vec<Home>,
141 162 }
142 163
143 164 impl Homes {
@@ -153,6 +174,14 @@ impl Homes {
153 174 .map(|(family, _)| *family)
154 175 }
155 176
177 + pub fn all(&self) -> impl Iterator<Item = &Home> {
178 + self.by_family.values().chain(&self.vacant)
179 + }
180 +
181 + pub fn is_home(&self, tile: UVec2) -> bool {
182 + self.all().any(|home| home.contains(tile))
183 + }
184 +
156 185 pub fn of(&self, family: Option<Family>) -> Option<&Home> {
157 186 self.by_family.get(&family?.0)
158 187 }
@@ -163,6 +192,7 @@ pub struct Affinity(pub HashMap<(Entity, Entity), u32>);
163 192
164 193 fn clear_families(mut homes: ResMut<Homes>, mut affinity: ResMut<Affinity>) {
165 194 homes.by_family.clear();
195 + homes.vacant.clear();
166 196 affinity.0.clear();
167 197 }
168 198
@@ -274,7 +304,7 @@ impl HomeContext<'_> {
274 304 self.zones.is_stockpile(tile)
275 305 || self.zones.is_farm(tile)
276 306 || self.blueprints.get(tile).is_some()
277 - || self.homes.owner(tile).is_some()
307 + || self.homes.is_home(tile)
278 308 }
279 309
280 310 fn open(&self, tile: IVec2) -> bool {
@@ -296,7 +326,8 @@ impl HomeContext<'_> {
296 326 if core {
297 327 self.open(tile)
298 328 } else {
299 - !self.map.in_bounds(tile) || !self.claimed(tile.as_uvec2())
329 + !self.map.in_bounds(tile)
330 + || (!self.claimed(tile.as_uvec2()) && self.map.move_cost(tile).is_some())
300 331 }
301 332 })
302 333 })
@@ -322,6 +353,56 @@ impl HomeContext<'_> {
322 353 }
323 354 }
324 355
356 +pub fn door_repair(
357 + map: &WorldMap,
358 + doors: &HashSet<UVec2>,
359 + reachable: &HashSet<UVec2>,
360 + home: &Home,
361 + toward: UVec2,
362 +) -> Option<(UVec2, BuildKind)> {
363 + let open_outside = |t: &UVec2| {
364 + let out = home.outward(*t);
365 + out.min_element() >= 0 && reachable.contains(&out.as_uvec2())
366 + };
367 + if home
368 + .door_sites()
369 + .any(|t| doors.contains(&t) && open_outside(&t))
370 + {
371 + return None;
372 + }
373 + let site = home
374 + .door_sites()
375 + .filter(|t| !doors.contains(t) && open_outside(t))
376 + .min_by_key(|t| {
377 + let d = t.as_ivec2() - toward.as_ivec2();
378 + (map.is_wall(t.as_ivec2()), d.length_squared(), t.y, t.x)
379 + })?;
380 + let kind = if map.is_wall(site.as_ivec2()) {
381 + BuildKind::Deconstruct
382 + } else {
383 + BuildKind::Structure(StructureKind::Door)
384 + };
385 + Some((site, kind))
386 +}
387 +
388 +pub fn abandoned_home(map: &WorldMap, doors: &HashSet<UVec2>, bed: UVec2) -> Option<Home> {
389 + let room = enclosed_room(map, doors, bed)?;
390 + let lo = room.iter().fold(UVec2::MAX, |a, t| a.min(*t));
391 + let hi = room.iter().fold(UVec2::ZERO, |a, t| a.max(*t));
392 + let size = hi - lo + UVec2::ONE;
393 + (size == HOME_INTERIOR && room.len() == size.element_product() as usize && lo.min_element() > 0)
394 + .then(|| Home::at(lo - UVec2::ONE))
395 +}
396 +
397 +pub fn surplus_beds(communal: &[UVec2], homeless_adults: usize, toward: UVec2) -> Vec<UVec2> {
398 + let mut beds = communal.to_vec();
399 + beds.sort_by_key(|b| {
400 + let d = b.as_ivec2() - toward.as_ivec2();
401 + (d.length_squared(), b.y, b.x)
402 + });
403 + beds.split_off(homeless_adults.min(beds.len()))
404 +}
405 +
325 406 pub fn roofs_needed(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> Vec<UVec2> {
326 407 let interior: Vec<UVec2> = home.interior().collect();
327 408 let Some(room) = enclosed_room(map, doors, interior[0]) else {
@@ -345,24 +426,93 @@ fn plan_homes(
345 426 mut blueprints: ResMut<Blueprints>,
346 427 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
347 428 items: Query<&TilePos, With<Item>>,
348 - families: Query<(&Family, Has<Partner>), With<Colonist>>,
429 + families: Query<(Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>,
349 430 ) {
350 431 if !tick.0.is_multiple_of(HOME_PLAN_INTERVAL_TICKS) {
351 432 return;
352 433 }
353 - let living: HashSet<u32> = families.iter().map(|(f, _)| f.0).collect();
354 - homes.by_family.retain(|family, _| living.contains(family));
355 - let doors: HashSet<UVec2> = structures
434 + let colony_home = zones.home(map.size / 2);
435 + let living: HashSet<u32> = families
356 436 .iter()
357 - .filter(|(_, s)| s.kind == StructureKind::Door)
358 - .map(|(pos, _)| pos.0)
437 + .filter_map(|(f, ..)| f)
438 + .map(|f| f.0)
359 439 .collect();
360 - let statues: HashSet<UVec2> = structures
440 + let extinct: Vec<u32> = homes
441 + .by_family
442 + .keys()
443 + .filter(|f| !living.contains(f))
444 + .copied()
445 + .collect();
446 + for family in extinct {
447 + if let Some(home) = homes.by_family.remove(&family) {
448 + homes.vacant.push(home);
449 + }
450 + }
451 + let of_kind = |kind: StructureKind| -> HashSet<UVec2> {
452 + structures
453 + .iter()
454 + .filter(|(_, s)| s.kind == kind)
455 + .map(|(pos, _)| pos.0)
456 + .collect()
457 + };
458 + let doors = of_kind(StructureKind::Door);
459 + let statues = of_kind(StructureKind::Statue);
460 + let beds = of_kind(StructureKind::Bed);
461 + for &bed in &beds {
462 + if !homes.is_home(bed)
463 + && let Some(home) = abandoned_home(&map, &doors, bed)
464 + && !homes.all().any(|h| h.ring().any(|t| home.contains(t)))
465 + {
466 + homes.vacant.push(home);
467 + }
468 + }
469 + let homeless_family = |homes: &Homes| {
470 + families
471 + .iter()
472 + .filter(|(_, partnered, _)| *partnered)
473 + .filter_map(|(f, ..)| f)
474 + .map(|f| f.0)
475 + .filter(|f| !homes.by_family.contains_key(f))
476 + .min()
477 + };
478 + while let Some(family) = homeless_family(&homes)
479 + && let Some(index) = (0..homes.vacant.len()).min_by_key(|&i| {
480 + let d = homes.vacant[i].lo.as_ivec2() - colony_home.as_ivec2();
481 + d.length_squared()
482 + })
483 + {
484 + let home = homes.vacant.swap_remove(index);
485 + homes.by_family.insert(family, home);
486 + }
487 + let mut reachable = HashSet::new();
488 + nearest_tile(&map, colony_home, usize::MAX, |p| {
489 + reachable.insert(p);
490 + false
491 + });
492 + let anchored = map.move_cost(colony_home.as_ivec2()).is_some();
493 + blueprints.0.retain(|tile, _| {
494 + !anchored
495 + || (-1..=1).any(|dy| {
496 + (-1..=1).any(|dx| {
497 + let near = tile.as_ivec2() + IVec2::new(dx, dy);
498 + near.min_element() >= 0 && reachable.contains(&near.as_uvec2())
499 + })
500 + })
501 + });
502 + let owned: HashSet<Home> = homes.by_family.values().copied().collect();
503 + let door = BuildKind::Structure(StructureKind::Door);
504 + let entries: HashSet<UVec2> = blueprints
505 + .0
361 506 .iter()
362 - .filter(|(_, s)| s.kind == StructureKind::Statue)
363 - .map(|(pos, _)| pos.0)
507 + .filter(|(_, kind)| **kind == door)
508 + .map(|(tile, _)| *tile)
509 + .chain(doors.iter().copied())
364 510 .collect();
365 - for home in homes.by_family.values() {
511 + for home in homes.all() {
512 + if let Some((tile, kind)) = door_repair(&map, &entries, &reachable, home, colony_home) {
513 + blueprints.0.insert(tile, kind);
514 + continue;
515 + }
366 516 if blueprints.0.keys().any(|t| home.contains(*t)) {
367 517 continue;
368 518 }
@@ -371,17 +521,24 @@ fn plan_homes(
371 521 for tile in roofs {
372 522 blueprints.0.insert(tile, BuildKind::Roof);
373 523 }
374 - if built && !statues.iter().any(|&t| home.contains(t)) {
524 + if built && owned.contains(home) && !statues.iter().any(|&t| home.contains(t)) {
375 525 let statue = BuildKind::Structure(StructureKind::Statue);
376 526 blueprints.0.insert(home.statue_tile(), statue);
377 527 }
378 528 }
379 - let Some(family) = families
529 + let homeless_adults = families
380 530 .iter()
381 - .filter(|(f, partnered)| *partnered && !homes.by_family.contains_key(&f.0))
382 - .map(|(f, _)| f.0)
383 - .min()
384 - else {
531 + .filter(|(family, _, child)| !child && homes.of(family.copied()).is_none())
532 + .count();
533 + let communal: Vec<UVec2> = beds
534 + .iter()
535 + .filter(|b| !homes.is_home(**b) && blueprints.get(**b).is_none() && reachable.contains(b))
536 + .copied()
537 + .collect();
538 + for bed in surplus_beds(&communal, homeless_adults, colony_home) {
539 + blueprints.0.insert(bed, BuildKind::Deconstruct);
540 + }
541 + let Some(family) = homeless_family(&homes) else {
385 542 return;
386 543 };
387 544 let occupied: HashSet<UVec2> = structures
@@ -389,7 +546,6 @@ fn plan_homes(
389 546 .map(|(pos, _)| pos.0)
390 547 .chain(items.iter().map(|pos| pos.0))
391 548 .collect();
392 - let colony_home = zones.home(map.size / 2);
393 549 let ctx = HomeContext {
394 550 map: &map,
395 551 zones: &zones,
@@ -680,4 +836,158 @@ mod tests {
680 836 Some(BuildKind::Structure(StructureKind::Statue))
681 837 );
682 838 }
839 +
840 + fn walled(map: &mut WorldMap, home: Home, door: UVec2) -> HashSet<UVec2> {
841 + for tile in home.ring().filter(|t| *t != door) {
842 + map.set_wall(tile, true);
843 + }
844 + HashSet::from([door])
845 + }
846 +
847 + fn reachable(map: &WorldMap, from: UVec2) -> HashSet<UVec2> {
848 + let mut seen = HashSet::new();
849 + nearest_tile(map, from, usize::MAX, |p| {
850 + seen.insert(p);
851 + false
852 + });
853 + seen
854 + }
855 +
856 + // New homes keep a walkable tile all around, so any door they get opens onto open ground.
857 + // Homes used to be packed wall to wall and their doors ended up facing a neighbour's wall.
858 + #[test]
859 + fn new_homes_never_touch_existing_walls() {
860 + let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Grass);
861 + for y in 0..40 {
862 + map.set_wall(UVec2::new(27, y), true);
863 + }
864 + let mut zones = Zones::default();
865 + zones.add_tiles(&map, [UVec2::new(20, 20)]);
866 + let home = plan_on(&map, &zones, &HashSet::new());
867 + for y in home.lo.y - 1..=home.hi.y + 1 {
868 + for x in home.lo.x - 1..=home.hi.x + 1 {
869 + assert!(!map.is_wall(IVec2::new(x as i32, y as i32)), "{home:?}");
870 + }
871 + }
872 + }
873 +
874 + // A home whose only door faces a wall can't be entered; it gets a new door on a side that
875 + // opens onto ground connected to the colony, knocking out a wall first if needed.
876 + #[test]
877 + fn homes_with_a_blocked_door_get_a_new_one() {
878 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
879 + let home = Home::at(UVec2::new(5, 5));
880 + let blocked = UVec2::new(7, 5);
881 + let doors = walled(&mut map, home, blocked);
882 + map.set_wall(UVec2::new(7, 4), true);
883 + let colony = UVec2::new(7, 0);
884 + let open = reachable(&map, colony);
885 + let (site, kind) = door_repair(&map, &doors, &open, &home, colony).unwrap();
886 + assert_eq!(kind, BuildKind::Deconstruct);
887 + assert!(home.door_sites().any(|t| t == site) && site != blocked);
888 + map.set_wall(site, false);
889 + let open = reachable(&map, colony);
890 + assert_eq!(
891 + door_repair(&map, &doors, &open, &home, colony),
892 + Some((site, BuildKind::Structure(StructureKind::Door)))
893 + );
894 + let fine = walled(
895 + &mut WorldMap::filled(UVec2::splat(20), Terrain::Grass),
896 + home,
897 + blocked,
898 + );
899 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
900 + assert_eq!(
901 + door_repair(&map, &fine, &reachable(&map, colony), &home, colony),
902 + None
903 + );
904 + }
905 +
906 + // Blueprints sealed off from the colony can never be built; they only inflate material
907 + // targets, so they are dropped (a home's own repairs reopen it and plan again).
908 + #[test]
909 + fn unreachable_blueprints_are_dropped() {
910 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
911 + let pocket = Home::at(UVec2::new(12, 12));
912 + for tile in pocket.ring() {
913 + map.set_wall(tile, true);
914 + }
915 + let mut app = App::new();
916 + app.insert_resource(map)
917 + .init_resource::<SimTick>()
918 + .init_resource::<Zones>()
919 + .init_resource::<Homes>()
920 + .insert_resource(Blueprints(HashMap::from([
921 + (UVec2::new(14, 14), BuildKind::Roof),
922 + (UVec2::new(2, 2), BuildKind::Roof),
923 + (UVec2::new(12, 12), BuildKind::Deconstruct),
924 + ])))
925 + .add_systems(Update, plan_homes);
926 + app.update();
927 + let blueprints = app.world().resource::<Blueprints>();
928 + assert_eq!(blueprints.get(UVec2::new(14, 14)), None);
929 + assert!(blueprints.get(UVec2::new(2, 2)).is_some());
930 + assert!(blueprints.get(UVec2::new(12, 12)).is_some());
931 + }
932 +
933 + // Couples move into the homes of families that died out before anyone builds anew, and
934 + // communal beds beyond the adults who still lack a home are taken down.
935 + #[test]
936 + fn vacant_homes_are_reused_and_spare_beds_removed() {
937 + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
938 + let old = Home::at(UVec2::new(5, 5));
939 + let door = UVec2::new(7, 5);
940 + walled(&mut map, old, door);
941 + for tile in old.interior() {
942 + map.set_roof(tile, true);
943 + }
944 + let mut app = App::new();
945 + app.insert_resource(map)
946 + .init_resource::<SimTick>()
947 + .init_resource::<Zones>()
948 + .init_resource::<Blueprints>()
949 + .init_resource::<Homes>()
950 + .add_systems(Update, plan_homes);
951 + let world = app.world_mut();
952 + let structure = |kind| Structure { kind };
953 + world.spawn((structure(StructureKind::Door), TilePos(door)));
954 + for bed in old.bed_tiles() {
955 + world.spawn((structure(StructureKind::Bed), TilePos(bed)));
956 + }
957 + for x in 20..24 {
958 + world.spawn((structure(StructureKind::Bed), TilePos(UVec2::new(x, 20))));
959 + }
960 + let a = world
961 + .spawn((Colonist { name: "Ada".into() }, Family(2)))
962 + .id();
963 + let b = world
964 + .spawn((
965 + Colonist {
966 + name: "Bram".into(),
967 + },
968 + Family(2),
969 + Partner(a),
970 + ))
971 + .id();
972 + world.entity_mut(a).insert(Partner(b));
973 + world.spawn(Colonist {
974 + name: "Cleo".into(),
975 + });
976 + app.update();
977 + let homes = app.world().resource::<Homes>();
978 + assert_eq!(homes.by_family.get(&2), Some(&old));
979 + let blueprints = app.world().resource::<Blueprints>();
980 + let removed = blueprints
981 + .0
982 + .values()
983 + .filter(|k| **k == BuildKind::Deconstruct)
984 + .count();
985 + assert_eq!(removed, 3);
986 + assert!(
987 + !blueprints
988 + .0
989 + .values()
990 + .any(|k| *k == BuildKind::Structure(StructureKind::Wall))
991 + );
992 + }
683 993 }

tasks.md +1 -0

@@ -41,6 +41,7 @@
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 43 - [x] Max speed fix: target searches respect A*'s no-corner-cutting rule (unreachable diagonal stacks caused repeated full-map path searches)
44 +- [x] Housing upkeep: vacant homes kept and reused by new couples, abandoned enclosed homes adopted, blocked doors repaired, homes keep a walkable gap, unreachable blueprints dropped, surplus communal beds deconstructed
44 45 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
45 46
46 47 ## Planned