Size homes to households, give solos rooms, expand homes by moving a wall

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 16:12 UTC
commit
91e856af5efd3295fad0cd59d03d0a2b8b6902de
parent
05bc45bc6a
tree
browse at this commit

3 files changed +311 -34

CLAUDE.md +1 -1
src/family.rs +309 -33
tasks.md +1 -0

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 = 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 -- `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). New homes via `HomeContext::plan`: 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.
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.
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/family.rs +309 -33

@@ -79,6 +79,7 @@ pub struct Home {
79 79 }
80 80
81 81 impl Home {
82 + #[cfg(test)]
82 83 pub fn at(lo: UVec2) -> Self {
83 84 Self {
84 85 lo,
@@ -86,6 +87,37 @@ impl Home {
86 87 }
87 88 }
88 89
90 + pub fn sized(lo: UVec2, interior: UVec2) -> Self {
91 + Self {
92 + lo,
93 + hi: lo + interior + UVec2::ONE,
94 + }
95 + }
96 +
97 + pub fn interior_size(&self) -> UVec2 {
98 + self.hi - self.lo - UVec2::ONE
99 + }
100 +
101 + pub fn area(&self) -> u32 {
102 + self.interior_size().element_product()
103 + }
104 +
105 + pub fn grown(&self, side: IVec2) -> Option<Self> {
106 + let lo = self.lo.as_ivec2() + side.min(IVec2::ZERO);
107 + let hi = self.hi.as_ivec2() + side.max(IVec2::ZERO);
108 + (lo.min_element() >= 0).then(|| Self {
109 + lo: lo.as_uvec2(),
110 + hi: hi.as_uvec2(),
111 + })
112 + }
113 +
114 + pub fn bed_tiles_for(&self, beds: usize) -> Vec<UVec2> {
115 + (0..beds as u32)
116 + .map(|i| UVec2::new(self.lo.x + 1 + i, self.hi.y - 1))
117 + .take_while(|t| t.x < self.hi.x - 1 || beds == 1)
118 + .collect()
119 + }
120 +
89 121 pub fn contains(&self, tile: UVec2) -> bool {
90 122 tile.cmpge(self.lo).all() && tile.cmple(self.hi).all()
91 123 }
@@ -150,7 +182,12 @@ impl Home {
150 182 })
151 183 }
152 184
153 - pub fn blueprints(&self, map: &WorldMap, toward: UVec2) -> HashMap<UVec2, BuildKind> {
185 + pub fn blueprints(
186 + &self,
187 + map: &WorldMap,
188 + toward: UVec2,
189 + beds: usize,
190 + ) -> HashMap<UVec2, BuildKind> {
154 191 let door = self.door_site(map, toward).unwrap_or(self.lo);
155 192 let mut plan: HashMap<UVec2, BuildKind> = self
156 193 .ring()
@@ -158,7 +195,7 @@ impl Home {
158 195 .map(|t| (t, BuildKind::Structure(StructureKind::Wall)))
159 196 .collect();
160 197 plan.insert(door, BuildKind::Structure(StructureKind::Door));
161 - for bed in self.bed_tiles() {
198 + for bed in self.bed_tiles_for(beds) {
162 199 plan.insert(bed, BuildKind::Structure(StructureKind::Bed));
163 200 }
164 201 plan
@@ -361,15 +398,61 @@ impl HomeContext<'_> {
361 398 (interior_open && door).then_some(shared)
362 399 }
363 400
401 + pub fn expansion(&self, home: Home, desired: UVec2) -> Option<(Home, Vec<UVec2>)> {
402 + let size = home.interior_size();
403 + let sides: Vec<IVec2> = if size.x < desired.x {
404 + vec![IVec2::X, IVec2::NEG_X]
405 + } else if size.y < desired.y {
406 + vec![IVec2::Y, IVec2::NEG_Y]
407 + } else {
408 + vec![IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y]
409 + };
410 + let wall = BuildKind::Structure(StructureKind::Wall);
411 + sides
412 + .into_iter()
413 + .filter_map(|side| {
414 + let grown = home.grown(side)?;
415 + if !self.map.in_bounds(grown.hi.as_ivec2()) {
416 + return None;
417 + }
418 + let new_ring: Vec<UVec2> = grown.ring().filter(|t| !home.contains(*t)).collect();
419 + let new_inside: Vec<UVec2> =
420 + grown.interior().filter(|t| !home.contains(*t)).collect();
421 + let mut shared = 0;
422 + for &t in &new_ring {
423 + if self.map.is_wall(t.as_ivec2()) || self.blueprints.get(t) == Some(wall) {
424 + shared += 1;
425 + } else if !self.open(t.as_ivec2()) || self.rooms.contains(t) {
426 + return None;
427 + }
428 + }
429 + let inside_open = new_inside
430 + .iter()
431 + .all(|t| self.open(t.as_ivec2()) && !self.rooms.contains(*t));
432 + let opened: Vec<UVec2> = home
433 + .ring()
434 + .filter(|t| grown.interior().any(|i| i == *t))
435 + .collect();
436 + inside_open.then_some((shared, grown, opened))
437 + })
438 + .max_by_key(|(shared, ..)| *shared)
439 + .map(|(_, grown, opened)| (grown, opened))
440 + }
441 +
442 + #[cfg(test)]
364 443 pub fn plan(&self, colony_home: UVec2) -> Option<Home> {
365 - let center = (HOME_INTERIOR / 2 + UVec2::ONE).as_ivec2();
444 + self.plan_sized(colony_home, HOME_INTERIOR)
445 + }
446 +
447 + pub fn plan_sized(&self, colony_home: UVec2, interior: UVec2) -> Option<Home> {
448 + let center = (interior / 2 + UVec2::ONE).as_ivec2();
366 449 let mut candidates: Vec<(usize, Home)> = Vec::new();
367 450 nearest_tile(self.map, colony_home, HOME_SEARCH_TILES, |p| {
368 451 let lo = p.as_ivec2() - center;
369 452 if lo.min_element() < 0 {
370 453 return false;
371 454 }
372 - let home = Home::at(lo.as_uvec2());
455 + let home = Home::sized(lo.as_uvec2(), interior);
373 456 let middle = (home.lo + home.hi).as_ivec2() / 2;
374 457 if (middle - colony_home.as_ivec2()).abs().max_element() < HOME_MIN_DISTANCE {
375 458 return false;
@@ -432,6 +515,31 @@ pub fn torn_down(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> bool {
432 515 intact * 2 < ring.len()
433 516 }
434 517
518 +pub fn desired_interior(adults: usize, children: usize) -> UVec2 {
519 + match (adults.max(1), children) {
520 + (1, 0) => UVec2::new(3, 2),
521 + (_, 0) => HOME_INTERIOR,
522 + (_, 1) => UVec2::new(5, 3),
523 + _ => UVec2::new(5, 4),
524 + }
525 +}
526 +
527 +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
528 +pub struct Household {
529 + pub partners: usize,
530 + pub children: usize,
531 +}
532 +
533 +impl Household {
534 + pub fn adults(&self) -> usize {
535 + self.partners.max(1)
536 + }
537 +
538 + pub fn desired(&self) -> UVec2 {
539 + desired_interior(self.adults(), self.children)
540 + }
541 +}
542 +
435 543 pub fn home_of_room(room: &Room) -> Option<Home> {
436 544 let (lo, hi) = room.bounds();
437 545 let size = hi - lo + UVec2::ONE;
@@ -454,6 +562,7 @@ pub fn surplus_beds(communal: &[UVec2], homeless_adults: usize, toward: UVec2) -
454 562
455 563 #[allow(clippy::too_many_arguments)]
456 564 fn plan_homes(
565 + mut commands: Commands,
457 566 tick: Res<SimTick>,
458 567 map: Res<WorldMap>,
459 568 zones: Res<Zones>,
@@ -461,7 +570,7 @@ fn plan_homes(
461 570 mut blueprints: ResMut<Blueprints>,
462 571 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
463 572 items: Query<&TilePos, With<Item>>,
464 - families: Query<(Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>,
573 + families: Query<(Entity, Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>,
465 574 mut colony_reach: ResMut<ColonyReach>,
466 575 mut flood: Local<Option<((u64, UVec2, UVec2), Reach)>>,
467 576 rooms: Res<Rooms>,
@@ -472,9 +581,18 @@ fn plan_homes(
472 581 let colony_home = zones.home(map.size / 2);
473 582 let living: HashSet<u32> = families
474 583 .iter()
475 - .filter_map(|(f, ..)| f)
584 + .filter_map(|(_, f, ..)| f)
476 585 .map(|f| f.0)
477 586 .collect();
587 + let mut households: HashMap<u32, Household> = HashMap::new();
588 + for (_, family, partnered, child) in &families {
589 + if let Some(family) = family {
590 + let household = households.entry(family.0).or_default();
591 + household.partners += partnered as usize;
592 + household.children += child as usize;
593 + }
594 + }
595 + let household = |family: u32| households.get(&family).copied().unwrap_or_default();
478 596 let extinct: Vec<u32> = homes
479 597 .by_family
480 598 .keys()
@@ -517,21 +635,36 @@ fn plan_homes(
517 635 let homeless_family = |homes: &Homes| {
518 636 families
519 637 .iter()
520 - .filter(|(_, partnered, _)| *partnered)
521 - .filter_map(|(f, ..)| f)
638 + .filter(|(_, _, partnered, _)| *partnered)
639 + .filter_map(|(_, f, ..)| f)
522 640 .map(|f| f.0)
523 641 .filter(|f| !homes.by_family.contains_key(f))
524 642 .min()
525 643 };
526 - while let Some(family) = homeless_family(&homes)
527 - && let Some(index) = (0..homes.vacant.len()).min_by_key(|&i| {
528 - let d = homes.vacant[i].lo.as_ivec2() - colony_home.as_ivec2();
529 - d.length_squared()
644 + let pick_vacant = |homes: &Homes, desired: UVec2| {
645 + (0..homes.vacant.len()).min_by_key(|&i| {
646 + let home = homes.vacant[i];
647 + let d = home.lo.as_ivec2() - colony_home.as_ivec2();
648 + (home.area() < desired.element_product(), d.length_squared())
530 649 })
650 + };
651 + while let Some(family) = homeless_family(&homes)
652 + && let Some(index) = pick_vacant(&homes, household(family).desired())
531 653 {
532 654 let home = homes.vacant.swap_remove(index);
533 655 homes.by_family.insert(family, home);
534 656 }
657 + let solo = families
658 + .iter()
659 + .filter(|(_, family, partnered, child)| {
660 + !partnered
661 + && !child
662 + && family.is_none_or(|f| {
663 + household(f.0).partners > 0 || !homes.by_family.contains_key(&f.0)
664 + })
665 + })
666 + .map(|(entity, ..)| entity)
667 + .min();
535 668 let anchored = map.move_cost(colony_home.as_ivec2()).is_some();
536 669 let key = (map.shape_version(), colony_home, map.size);
537 670 let stale = flood.as_ref().is_none_or(|(cached, _)| *cached != key)
@@ -545,7 +678,8 @@ fn plan_homes(
545 678 blueprints
546 679 .0
547 680 .retain(|tile, _| !anchored || reachable.near(*tile));
548 - let owned: HashSet<Home> = homes.by_family.values().copied().collect();
681 + let owned: HashMap<Home, u32> = homes.by_family.iter().map(|(f, h)| (*h, *f)).collect();
682 + let mut expansions: Vec<(u32, Home, Vec<UVec2>)> = Vec::new();
549 683 let door = BuildKind::Structure(StructureKind::Door);
550 684 let entries: HashSet<UVec2> = blueprints
551 685 .0
@@ -563,7 +697,20 @@ fn plan_homes(
563 697 continue;
564 698 }
565 699 let built = home.interior().all(|t| map.is_roofed(t.as_ivec2()));
566 - if built && owned.contains(home) {
700 + if let Some(&family) = owned.get(home).filter(|_| built) {
701 + let needs = household(family);
702 + let desired = needs.desired();
703 + if home.area() < desired.element_product() {
704 + expansions.push((family, *home, Vec::new()));
705 + continue;
706 + }
707 + let bed = BuildKind::Structure(StructureKind::Bed);
708 + let have = beds.iter().filter(|b| home.contains(**b)).count();
709 + for tile in home.bed_tiles_for(needs.adults()).into_iter().skip(have) {
710 + if !beds.contains(&tile) {
711 + blueprints.0.entry(tile).or_insert(bed);
712 + }
713 + }
567 714 if !hearths.iter().any(|&t| home.contains(t)) {
568 715 let hearth = BuildKind::Structure(StructureKind::Hearth);
569 716 blueprints.0.insert(home.hearth_tile(), hearth);
@@ -579,7 +726,7 @@ fn plan_homes(
579 726 }
580 727 let homeless_adults = families
581 728 .iter()
582 - .filter(|(family, _, child)| !child && homes.of(family.copied()).is_none())
729 + .filter(|(_, family, _, child)| !child && homes.of(family.copied()).is_none())
583 730 .count();
584 731 let communal: Vec<UVec2> = beds
585 732 .iter()
@@ -589,9 +736,6 @@ fn plan_homes(
589 736 for bed in surplus_beds(&communal, homeless_adults, colony_home) {
590 737 blueprints.0.insert(bed, BuildKind::Deconstruct);
591 738 }
592 - let Some(family) = homeless_family(&homes) else {
593 - return;
594 - };
595 739 let occupied: HashSet<UVec2> = structures
596 740 .iter()
597 741 .map(|(pos, _)| pos.0)
@@ -605,11 +749,69 @@ fn plan_homes(
605 749 homes: &homes,
606 750 occupied: &occupied,
607 751 };
608 - if let Some(home) = ctx.plan(colony_home) {
609 - let plan = home.blueprints(&map, colony_home);
610 - blueprints.0.extend(plan);
611 - homes.by_family.insert(family, home);
752 + let mut grown_homes = Vec::new();
753 + for (family, home, _) in expansions {
754 + if let Some((grown, opened)) = ctx.expansion(home, household(family).desired()) {
755 + grown_homes.push((family, grown, opened));
756 + }
757 + }
758 + enum Plan {
759 + Family(u32, Home),
760 + SoloVacant(Entity, usize),
761 + Solo(Entity, Home),
762 + }
763 + let plan = if let Some(family) = homeless_family(&homes) {
764 + ctx.plan_sized(colony_home, household(family).desired())
765 + .map(|home| Plan::Family(family, home))
766 + } else if let Some(entity) = solo {
767 + let desired = desired_interior(1, 0);
768 + match pick_vacant(&homes, desired)
769 + .filter(|&i| homes.vacant[i].area() <= HOME_INTERIOR.element_product())
770 + {
771 + Some(index) => Some(Plan::SoloVacant(entity, index)),
772 + None => ctx
773 + .plan_sized(colony_home, desired)
774 + .map(|home| Plan::Solo(entity, home)),
775 + }
776 + } else {
777 + None
778 + };
779 + let wall = BuildKind::Structure(StructureKind::Wall);
780 + for (family, grown, opened) in grown_homes {
781 + for tile in grown.ring().filter(|t| !map.is_wall(t.as_ivec2())) {
782 + if !doors.contains(&tile) {
783 + blueprints.0.entry(tile).or_insert(wall);
784 + }
785 + }
786 + for tile in opened {
787 + if map.is_wall(tile.as_ivec2()) || doors.contains(&tile) {
788 + blueprints.0.insert(tile, BuildKind::Deconstruct);
789 + } else {
790 + blueprints.0.remove(&tile);
791 + }
792 + }
793 + homes.by_family.insert(family, grown);
612 794 }
795 + let (family, home, beds) = match plan {
796 + None => return,
797 + Some(Plan::Family(family, home)) => (family, home, household(family).adults()),
798 + Some(Plan::SoloVacant(entity, index)) => {
799 + let family = homes.new_family();
800 + commands.entity(entity).insert(family);
801 + let home = homes.vacant.swap_remove(index);
802 + homes.by_family.insert(family.0, home);
803 + return;
804 + }
805 + Some(Plan::Solo(entity, home)) => {
806 + let family = homes.new_family();
807 + commands.entity(entity).insert(family);
808 + (family.0, home, 1)
809 + }
810 + };
811 + blueprints
812 + .0
813 + .extend(home.blueprints(&map, colony_home, beds));
814 + homes.by_family.insert(family, home);
613 815 }
614 816
615 817 #[allow(clippy::too_many_arguments, clippy::type_complexity)]
@@ -812,7 +1014,7 @@ mod tests {
812 1014 fn a_home_is_a_walled_room_with_a_door_and_two_beds() {
813 1015 let home = Home::at(UVec2::new(10, 10));
814 1016 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
815 - let plan = home.blueprints(&map, UVec2::new(12, 0));
1017 + let plan = home.blueprints(&map, UVec2::new(12, 0), 2);
816 1018 let count = |kind: StructureKind| {
817 1019 plan.values()
818 1020 .filter(|k| **k == BuildKind::Structure(kind))
@@ -881,7 +1083,11 @@ mod tests {
881 1083 .interior()
882 1084 .filter(|t| blueprints.get(*t) == Some(BuildKind::Floor))
883 1085 .count();
884 - assert_eq!(floors, home.interior().count() - 2);
1086 + assert_eq!(floors, home.interior().count() - 3);
1087 + assert_eq!(
1088 + blueprints.get(home.bed_tiles_for(1)[0]),
1089 + Some(BuildKind::Structure(StructureKind::Bed))
1090 + );
885 1091 assert_eq!(
886 1092 blueprints.get(home.hearth_tile()),
887 1093 Some(BuildKind::Structure(StructureKind::Hearth))
@@ -911,7 +1117,7 @@ mod tests {
911 1117 zones.add_tiles(&map, [UVec2::new(20, 20)]);
912 1118 let home = plan_on(&map, &zones, &HashSet::new());
913 1119 assert!(home.lo.x == 27 || home.hi.x == 27, "{home:?}");
914 - let plan = home.blueprints(&map, UVec2::new(20, 20));
1120 + let plan = home.blueprints(&map, UVec2::new(20, 20), 2);
915 1121 assert!(plan.keys().all(|t| t.x != 27));
916 1122 let door = plan
917 1123 .iter()
@@ -982,8 +1188,9 @@ mod tests {
982 1188 assert!(blueprints.get(UVec2::new(12, 12)).is_some());
983 1189 }
984 1190
985 - // Couples move into the homes of families that died out before anyone builds anew, and
986 - // communal beds beyond the adults who still lack a home are taken down.
1191 + // Couples move into the homes of families that died out before anyone builds anew, a solo
1192 + // colonist plans a small room of their own, and communal beds beyond the adults who still
1193 + // lack a home are taken down.
987 1194 #[test]
988 1195 fn vacant_homes_are_reused_and_spare_beds_removed() {
989 1196 let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
@@ -1038,12 +1245,10 @@ mod tests {
1038 1245 .filter(|k| **k == BuildKind::Deconstruct)
1039 1246 .count();
1040 1247 assert_eq!(removed, 3);
1041 - assert!(
1042 - !blueprints
1043 - .0
1044 - .values()
1045 - .any(|k| *k == BuildKind::Structure(StructureKind::Wall))
1046 - );
1248 + let homes = app.world().resource::<Homes>();
1249 + assert_eq!(homes.by_family.len(), 2);
1250 + let solo = homes.by_family.values().find(|h| **h != old).unwrap();
1251 + assert_eq!(solo.interior_size(), desired_interior(1, 0));
1047 1252 }
1048 1253
1049 1254 // When the player demolishes a home, the family stops treating it as theirs (and plans a new
@@ -1080,4 +1285,75 @@ mod tests {
1080 1285 app.update();
1081 1286 assert!(app.world().resource::<Homes>().of(Some(Family(1))) != Some(&home));
1082 1287 }
1288 +
1289 + // Homes are sized to the household (a solo room, a couple's room, more space per child).
1290 + // When a family outgrows its home, colonists knock out one side wall and rebuild it further
1291 + // out, reusing walls that already stand there.
1292 + #[test]
1293 + fn growing_families_move_a_wall_out() {
1294 + assert_eq!(desired_interior(1, 0), UVec2::new(3, 2));
1295 + assert_eq!(desired_interior(2, 1), UVec2::new(5, 3));
1296 + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
1297 + let home = Home::at(UVec2::new(10, 10));
1298 + let door = UVec2::new(12, 10);
1299 + for tile in home.ring().filter(|t| *t != door) {
1300 + map.set_wall(tile, true);
1301 + }
1302 + for tile in home.interior() {
1303 + map.set_roof(tile, true);
1304 + }
1305 + let mut app = App::new();
1306 + app.insert_resource(map.clone())
1307 + .init_resource::<SimTick>()
1308 + .init_resource::<Zones>()
1309 + .init_resource::<Blueprints>()
1310 + .init_resource::<ColonyReach>()
1311 + .insert_resource(Rooms::detect(&map, &HashSet::from([door])))
1312 + .insert_resource(Homes {
1313 + by_family: HashMap::from([(1, home)]),
1314 + ..default()
1315 + })
1316 + .add_systems(Update, plan_homes);
1317 + let world = app.world_mut();
1318 + world.spawn((
1319 + Structure {
1320 + kind: StructureKind::Door,
1321 + },
1322 + TilePos(door),
1323 + ));
1324 + let a = world
1325 + .spawn((Colonist { name: "Ada".into() }, Family(1)))
1326 + .id();
1327 + let b = world
1328 + .spawn((
1329 + Colonist {
1330 + name: "Bram".into(),
1331 + },
1332 + Family(1),
1333 + Partner(a),
1334 + ))
1335 + .id();
1336 + world.entity_mut(a).insert(Partner(b));
1337 + world.spawn((
1338 + Colonist {
1339 + name: "Cleo".into(),
1340 + },
1341 + Family(1),
1342 + Child { born: 0 },
1343 + ));
1344 + app.update();
1345 + let grown = app.world().resource::<Homes>().by_family[&1];
1346 + assert_eq!(grown.interior_size(), UVec2::new(5, 3));
1347 + let blueprints = app.world().resource::<Blueprints>();
1348 + let opened = home.ring().filter(|t| grown.interior().any(|i| i == *t));
1349 + for tile in opened {
1350 + assert_eq!(blueprints.get(tile), Some(BuildKind::Deconstruct));
1351 + }
1352 + let walls = grown
1353 + .ring()
1354 + .filter(|t| !home.contains(*t))
1355 + .filter(|t| blueprints.get(*t) == Some(BuildKind::Structure(StructureKind::Wall)))
1356 + .count();
1357 + assert_eq!(walls, 5);
1358 + }
1083 1359 }

tasks.md +1 -0

@@ -53,6 +53,7 @@
53 53 - [x] Demolish tool (Q / toolbar): drag to have colonists take down structures and roofs; torn-down homes are forgotten
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 +- [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
56 57 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
57 58
58 59 ## Planned