Detect rooms dynamically, share walls, remove bad doors, add room quality

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 15:56 UTC
commit
05bc45bc6ac5c1d77e0182766c92f7adb649018c
parent
76019b8ad5
tree
browse at this commit

11 files changed +534 -121

CLAUDE.md +2 -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, 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.
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.
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.
@@ -55,6 +55,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
55 55 - Arrivals get a real `Path` home (up to ARRIVAL_ATTEMPTS edge tiles tried); a bare MoveTo job without a path finishes at once, which used to strand newcomers at the map edge.
56 56 - Hearths: `StructureKind::Hearth` (6 stone, fuel 24 h, `refuel_cost` 4 wood vs build cost, light 3.5, heat 22 → warms the home), planned in every owned built home. `Recipe::stations()` (Meal: Campfire or Hearth; Tools: Workbench); `find_crafting(ctx, from, recipe, own_home)` only allows a Hearth inside the cook's own home and prefers it. `find_refuel` covers hearths anywhere. `find_haul` leaves Meal stacks on home tiles (`Homes::is_home`) so family meals stay home.
57 57 - Growth: `prosperous` and births need GROWTH_FOOD_DAYS 3 × colonists of stored nutrition (1× let hearth cooking boom births to 120 then famine). `find_food` takes `ColonyReach` (nearest by distance in reach); a colonist whose food search fails skips food searches and starvation urgency for FOOD_RETRY_TICKS 60 (`GatherMemo.food` per entity) — starving colonists re-searching every tick made a famine cost ~4.8 ms/tick.
58 +- `src/rooms.rs` dynamic rooms. `detect_rooms(map, doors)`: 4-connected regions bounded by walls/doors/impassable terrain that don't touch the map edge, are MIN_ROOM_TILES 4..=MAX_ROOM_TILES 120 and touch ≥1 door. `Rooms` resource (`at(tile)`, `contains`, `Rooms::detect` for tests) recomputed by `update_rooms` every 60 ticks when (`shape_version`, door tiles) change; quality every 300 ticks: `room_quality` = 0.1 + 0.2·space(n/16) + 0.25·floor share + 0.15·roof share + 0.2 statue + 0.1 hearth − 0.2 if crowded (beds·3 > tiles). `roof_rooms`: rooms with a bed or hearth get Roof blueprints (replaces home roofs_needed). `remove_bad_doors`: doors with ≥3 blocked orthogonal sides (`bad_door`) → Deconstruct. Hover shows room size/quality. Mood: `ROOM_MOOD` 0.3 × quality of the family home's room (replaces statue/floor bonuses).
58 59 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
59 60
60 61 ## Performance notes

src/building.rs +6 -1

@@ -8,6 +8,7 @@ use crate::{
8 8 colonist::Colonist,
9 9 family::Homes,
10 10 items::{Item, ItemKind},
11 + rooms::Rooms,
11 12 selection::{Cursor, update_cursor},
12 13 sim::{SimSystems, SimTick},
13 14 structures::{Structure, StructureKind},
@@ -268,6 +269,7 @@ pub fn plan_shelter(
268 269 items: Query<&TilePos, With<Item>>,
269 270 mut blueprints: ResMut<Blueprints>,
270 271 homes: Res<Homes>,
272 + rooms: Res<Rooms>,
271 273 ) {
272 274 let shelter_pending = blueprints
273 275 .0
@@ -281,7 +283,10 @@ pub fn plan_shelter(
281 283 let beds: Vec<UVec2> = structures
282 284 .iter()
283 285 .filter(|(pos, kind)| {
284 - *kind == StructureKind::Bed && !map.is_roofed(pos.as_ivec2()) && !homes.is_home(*pos)
286 + *kind == StructureKind::Bed
287 + && !map.is_roofed(pos.as_ivec2())
288 + && !homes.is_home(*pos)
289 + && !rooms.contains(*pos)
285 290 })
286 291 .map(|(pos, _)| *pos)
287 292 .collect();

src/family.rs +90 -78

@@ -4,13 +4,14 @@ use std::collections::{HashMap, HashSet};
4 4 use bevy::prelude::*;
5 5
6 6 use crate::{
7 - building::{Blueprints, BuildKind, enclosed_room},
7 + building::{Blueprints, BuildKind},
8 8 colonist::{Colonist, ColonistAssets, colonist_name, free_name_index, spawn_colonist},
9 9 economy::in_colony,
10 10 items::{Item, ItemAssets},
11 11 pathfinding::{ColonyReach, Reach, nearest_tile},
12 12 population::{ColonyLog, GROWTH_FOOD_DAYS, MAX_COLONISTS, food_in_store},
13 13 rng::SimRng,
14 + rooms::{Room, Rooms},
14 15 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 16 structures::{Structure, StructureKind},
16 17 world::{Terrain, TilePos, WorldMap},
@@ -49,7 +50,8 @@ const BIRTH_HOUR_TICK: u64 = 20 * TICKS_PER_HOUR;
49 50 const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
50 51 const HOME_PLAN_INTERVAL_TICKS: u64 = 300;
51 52 const HOME_SEARCH_TILES: usize = 6000;
52 -const HOME_CLEARANCE: i32 = 1;
53 +const HOME_CANDIDATES: usize = 40;
54 +const MIN_HOME_INTERIOR: UVec2 = UVec2::new(3, 2);
53 55
54 56 #[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Hash)]
55 57 pub struct Family(pub u32);
@@ -136,18 +138,23 @@ impl Home {
136 138 UVec2::new(self.hi.x - 1, self.lo.y + 1)
137 139 }
138 140
139 - pub fn blueprints(&self, toward: UVec2) -> HashMap<UVec2, BuildKind> {
140 - let door = self
141 - .door_sites()
141 + pub fn door_site(&self, map: &WorldMap, toward: UVec2) -> Option<UVec2> {
142 + self.door_sites()
143 + .filter(|t| !map.is_wall(t.as_ivec2()))
144 + .filter(|t| map.move_cost(self.outward(*t)).is_some())
142 145 .min_by(|a, b| {
143 146 a.as_vec2()
144 147 .distance_squared(toward.as_vec2())
145 148 .total_cmp(&b.as_vec2().distance_squared(toward.as_vec2()))
146 149 .then((a.y, a.x).cmp(&(b.y, b.x)))
147 150 })
148 - .unwrap_or(self.lo);
151 + }
152 +
153 + pub fn blueprints(&self, map: &WorldMap, toward: UVec2) -> HashMap<UVec2, BuildKind> {
154 + let door = self.door_site(map, toward).unwrap_or(self.lo);
149 155 let mut plan: HashMap<UVec2, BuildKind> = self
150 156 .ring()
157 + .filter(|t| !map.is_wall(t.as_ivec2()))
151 158 .map(|t| (t, BuildKind::Structure(StructureKind::Wall)))
152 159 .collect();
153 160 plan.insert(door, BuildKind::Structure(StructureKind::Door));
@@ -302,6 +309,7 @@ fn bond(
302 309 }
303 310
304 311 pub struct HomeContext<'a> {
312 + pub rooms: &'a Rooms,
305 313 pub map: &'a WorldMap,
306 314 pub zones: &'a Zones,
307 315 pub blueprints: &'a Blueprints,
@@ -326,26 +334,36 @@ impl HomeContext<'_> {
326 334 && !self.claimed(tile.as_uvec2())
327 335 }
328 336
329 - fn fits(&self, home: Home) -> bool {
330 - let (lo, hi) = (home.lo.as_ivec2(), home.hi.as_ivec2());
331 - let (outer_lo, outer_hi) = (lo - HOME_CLEARANCE, hi + HOME_CLEARANCE);
332 - (outer_lo.y..=outer_hi.y).all(|y| {
333 - (outer_lo.x..=outer_hi.x).all(|x| {
334 - let tile = IVec2::new(x, y);
335 - let core = tile.cmpge(lo).all() && tile.cmple(hi).all();
336 - if core {
337 - self.open(tile)
338 - } else {
339 - !self.map.in_bounds(tile)
340 - || (!self.claimed(tile.as_uvec2()) && self.map.move_cost(tile).is_some())
341 - }
342 - })
343 - })
337 + fn shared_walls(&self, home: Home) -> Option<usize> {
338 + if !self.map.in_bounds(home.hi.as_ivec2()) {
339 + return None;
340 + }
341 + let wall = BuildKind::Structure(StructureKind::Wall);
342 + let mut shared = 0;
343 + for tile in home.ring() {
344 + if self.map.is_wall(tile.as_ivec2()) || self.blueprints.get(tile) == Some(wall) {
345 + shared += 1;
346 + } else if !self.open(tile.as_ivec2()) || self.rooms.contains(tile) {
347 + return None;
348 + }
349 + }
350 + let interior_open = home
351 + .interior()
352 + .all(|t| self.open(t.as_ivec2()) && !self.rooms.contains(t));
353 + let door = home.door_sites().any(|t| {
354 + let out = home.outward(t);
355 + !self.map.is_wall(t.as_ivec2())
356 + && self.blueprints.get(t).is_none()
357 + && self.map.move_cost(out).is_some()
358 + && !self.rooms.contains(out.as_uvec2())
359 + && self.blueprints.get(out.as_uvec2()) != Some(wall)
360 + });
361 + (interior_open && door).then_some(shared)
344 362 }
345 363
346 364 pub fn plan(&self, colony_home: UVec2) -> Option<Home> {
347 365 let center = (HOME_INTERIOR / 2 + UVec2::ONE).as_ivec2();
348 - let mut found = None;
366 + let mut candidates: Vec<(usize, Home)> = Vec::new();
349 367 nearest_tile(self.map, colony_home, HOME_SEARCH_TILES, |p| {
350 368 let lo = p.as_ivec2() - center;
351 369 if lo.min_element() < 0 {
@@ -356,10 +374,16 @@ impl HomeContext<'_> {
356 374 if (middle - colony_home.as_ivec2()).abs().max_element() < HOME_MIN_DISTANCE {
357 375 return false;
358 376 }
359 - found = self.fits(home).then_some(home);
360 - found.is_some()
361 - })?;
362 - found
377 + if let Some(shared) = self.shared_walls(home) {
378 + candidates.push((shared, home));
379 + }
380 + candidates.len() >= HOME_CANDIDATES
381 + });
382 + candidates
383 + .into_iter()
384 + .enumerate()
385 + .max_by_key(|&(order, (shared, _))| (shared, std::cmp::Reverse(order)))
386 + .map(|(_, (_, home))| home)
363 387 }
364 388 }
365 389
@@ -408,13 +432,15 @@ pub fn torn_down(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> bool {
408 432 intact * 2 < ring.len()
409 433 }
410 434
411 -pub fn abandoned_home(map: &WorldMap, doors: &HashSet<UVec2>, bed: UVec2) -> Option<Home> {
412 - let room = enclosed_room(map, doors, bed)?;
413 - let lo = room.iter().fold(UVec2::MAX, |a, t| a.min(*t));
414 - let hi = room.iter().fold(UVec2::ZERO, |a, t| a.max(*t));
435 +pub fn home_of_room(room: &Room) -> Option<Home> {
436 + let (lo, hi) = room.bounds();
415 437 let size = hi - lo + UVec2::ONE;
416 - (size == HOME_INTERIOR && room.len() == size.element_product() as usize && lo.min_element() > 0)
417 - .then(|| Home::at(lo - UVec2::ONE))
438 + (room.is_rectangle() && size.cmpge(MIN_HOME_INTERIOR).all() && lo.min_element() > 0).then(
439 + || Home {
440 + lo: lo - UVec2::ONE,
441 + hi: hi + UVec2::ONE,
442 + },
443 + )
418 444 }
419 445
420 446 pub fn surplus_beds(communal: &[UVec2], homeless_adults: usize, toward: UVec2) -> Vec<UVec2> {
@@ -426,20 +452,6 @@ pub fn surplus_beds(communal: &[UVec2], homeless_adults: usize, toward: UVec2) -
426 452 beds.split_off(homeless_adults.min(beds.len()))
427 453 }
428 454
429 -pub fn roofs_needed(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> Vec<UVec2> {
430 - let interior: Vec<UVec2> = home.interior().collect();
431 - let Some(room) = enclosed_room(map, doors, interior[0]) else {
432 - return Vec::new();
433 - };
434 - if room.len() != interior.len() {
435 - return Vec::new();
436 - }
437 - interior
438 - .into_iter()
439 - .filter(|t| !map.is_roofed(t.as_ivec2()))
440 - .collect()
441 -}
442 -
443 455 #[allow(clippy::too_many_arguments)]
444 456 fn plan_homes(
445 457 tick: Res<SimTick>,
@@ -452,6 +464,7 @@ fn plan_homes(
452 464 families: Query<(Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>,
453 465 mut colony_reach: ResMut<ColonyReach>,
454 466 mut flood: Local<Option<((u64, UVec2, UVec2), Reach)>>,
467 + rooms: Res<Rooms>,
455 468 ) {
456 469 if !tick.0.is_multiple_of(HOME_PLAN_INTERVAL_TICKS) {
457 470 return;
@@ -490,10 +503,13 @@ fn plan_homes(
490 503 let statues = of_kind(StructureKind::Statue);
491 504 let hearths = of_kind(StructureKind::Hearth);
492 505 let beds = of_kind(StructureKind::Bed);
493 - for &bed in &beds {
494 - if !homes.is_home(bed)
495 - && let Some(home) = abandoned_home(&map, &doors, bed)
496 - && !homes.all().any(|h| h.ring().any(|t| home.contains(t)))
506 + for room in &rooms.rooms {
507 + let room_beds = beds.iter().filter(|b| room.tiles.contains(b)).count();
508 + if room_beds >= 2
509 + && let Some(home) = home_of_room(room)
510 + && !homes
511 + .all()
512 + .any(|h| h.interior().any(|t| room.tiles.contains(&t)))
497 513 {
498 514 homes.vacant.push(home);
499 515 }
@@ -546,11 +562,7 @@ fn plan_homes(
546 562 if blueprints.0.keys().any(|t| home.contains(*t)) {
547 563 continue;
548 564 }
549 - let roofs = roofs_needed(&map, &doors, home);
550 - let built = roofs.is_empty() && home.interior().all(|t| map.is_roofed(t.as_ivec2()));
551 - for tile in roofs {
552 - blueprints.0.insert(tile, BuildKind::Roof);
553 - }
565 + let built = home.interior().all(|t| map.is_roofed(t.as_ivec2()));
554 566 if built && owned.contains(home) {
555 567 if !hearths.iter().any(|&t| home.contains(t)) {
556 568 let hearth = BuildKind::Structure(StructureKind::Hearth);
@@ -586,6 +598,7 @@ fn plan_homes(
586 598 .chain(items.iter().map(|pos| pos.0))
587 599 .collect();
588 600 let ctx = HomeContext {
601 + rooms: &rooms,
589 602 map: &map,
590 603 zones: &zones,
591 604 blueprints: &blueprints,
@@ -593,7 +606,8 @@ fn plan_homes(
593 606 occupied: &occupied,
594 607 };
595 608 if let Some(home) = ctx.plan(colony_home) {
596 - blueprints.0.extend(home.blueprints(colony_home));
609 + let plan = home.blueprints(&map, colony_home);
610 + blueprints.0.extend(plan);
597 611 homes.by_family.insert(family, home);
598 612 }
599 613 }
@@ -759,6 +773,7 @@ mod tests {
759 773
760 774 fn plan_on(map: &WorldMap, zones: &Zones, occupied: &HashSet<UVec2>) -> Home {
761 775 HomeContext {
776 + rooms: &Rooms::detect(map, &HashSet::new()),
762 777 map,
763 778 zones,
764 779 blueprints: &Blueprints::default(),
@@ -796,7 +811,8 @@ mod tests {
796 811 #[test]
797 812 fn a_home_is_a_walled_room_with_a_door_and_two_beds() {
798 813 let home = Home::at(UVec2::new(10, 10));
799 - let plan = home.blueprints(UVec2::new(12, 0));
814 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
815 + let plan = home.blueprints(&map, UVec2::new(12, 0));
800 816 let count = |kind: StructureKind| {
801 817 plan.values()
802 818 .filter(|k| **k == BuildKind::Structure(kind))
@@ -820,19 +836,6 @@ mod tests {
820 836 }
821 837 }
822 838
823 - #[test]
824 - fn finished_homes_get_a_roof() {
825 - let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
826 - let home = Home::at(UVec2::new(5, 5));
827 - let door = UVec2::new(7, 5);
828 - for tile in home.ring().filter(|t| *t != door) {
829 - map.set_wall(tile, true);
830 - }
831 - assert!(roofs_needed(&map, &HashSet::new(), &home).is_empty());
832 - let roofs = roofs_needed(&map, &HashSet::from([door]), &home);
833 - assert_eq!(roofs.len(), (HOME_INTERIOR.x * HOME_INTERIOR.y) as usize);
834 - }
835 -
836 839 // Gold has one use: a statue in each finished home, which lifts the family's mood. Planning
837 840 // it is what makes the colony want gold at all. Finished homes also get a hearth, where the
838 841 // family cooks (one campfire can't feed a big colony) and which heats the home, and a wooden
@@ -858,6 +861,7 @@ mod tests {
858 861 ..default()
859 862 })
860 863 .init_resource::<ColonyReach>()
864 + .init_resource::<Rooms>()
861 865 .add_systems(Update, plan_homes);
862 866 app.world_mut().spawn((
863 867 Structure {
@@ -895,10 +899,10 @@ mod tests {
895 899 Reach::flood(map, from)
896 900 }
897 901
898 - // New homes keep a walkable tile all around, so any door they get opens onto open ground.
899 - // Homes used to be packed wall to wall and their doors ended up facing a neighbour's wall.
902 + // Homes reuse walls that are already standing (a shared wall instead of two side by side) and
903 + // only plan the missing ones; the door goes on a side that opens onto walkable ground.
900 904 #[test]
901 - fn new_homes_never_touch_existing_walls() {
905 + fn new_homes_share_existing_walls() {
902 906 let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Grass);
903 907 for y in 0..40 {
904 908 map.set_wall(UVec2::new(27, y), true);
@@ -906,11 +910,15 @@ mod tests {
906 910 let mut zones = Zones::default();
907 911 zones.add_tiles(&map, [UVec2::new(20, 20)]);
908 912 let home = plan_on(&map, &zones, &HashSet::new());
909 - for y in home.lo.y - 1..=home.hi.y + 1 {
910 - for x in home.lo.x - 1..=home.hi.x + 1 {
911 - assert!(!map.is_wall(IVec2::new(x as i32, y as i32)), "{home:?}");
912 - }
913 - }
913 + assert!(home.lo.x == 27 || home.hi.x == 27, "{home:?}");
914 + let plan = home.blueprints(&map, UVec2::new(20, 20));
915 + assert!(plan.keys().all(|t| t.x != 27));
916 + let door = plan
917 + .iter()
918 + .find(|(_, k)| **k == BuildKind::Structure(StructureKind::Door))
919 + .map(|(t, _)| *t)
920 + .unwrap();
921 + assert!(map.move_cost(home.outward(door)).is_some());
914 922 }
915 923
916 924 // A home whose only door faces a wall can't be entered; it gets a new door on a side that
@@ -965,6 +973,7 @@ mod tests {
965 973 (UVec2::new(12, 12), BuildKind::Deconstruct),
966 974 ])))
967 975 .init_resource::<ColonyReach>()
976 + .init_resource::<Rooms>()
968 977 .add_systems(Update, plan_homes);
969 978 app.update();
970 979 let blueprints = app.world().resource::<Blueprints>();
@@ -985,12 +994,14 @@ mod tests {
985 994 map.set_roof(tile, true);
986 995 }
987 996 let mut app = App::new();
997 + let map_for_rooms = map.clone();
988 998 app.insert_resource(map)
989 999 .init_resource::<SimTick>()
990 1000 .init_resource::<Zones>()
991 1001 .init_resource::<Blueprints>()
992 1002 .init_resource::<Homes>()
993 1003 .init_resource::<ColonyReach>()
1004 + .insert_resource(Rooms::detect(&map_for_rooms, &HashSet::from([door])))
994 1005 .add_systems(Update, plan_homes);
995 1006 let world = app.world_mut();
996 1007 let structure = |kind| Structure { kind };
@@ -1062,6 +1073,7 @@ mod tests {
1062 1073 by_family: HashMap::from([(1, home)]),
1063 1074 ..default()
1064 1075 })
1076 + .init_resource::<Rooms>()
1065 1077 .add_systems(Update, plan_homes);
1066 1078 app.world_mut()
1067 1079 .spawn((Colonist { name: "Ada".into() }, Family(1)));

src/jobs/mod.rs +11 -2

@@ -1120,6 +1120,7 @@ pub fn flee_from(map: &WorldMap, from: UVec2, threat: UVec2) -> Option<Vec<UVec2
1120 1120 }
1121 1121
1122 1122 pub fn find_tending(
1123 + map: &WorldMap,
1123 1124 patients: &[(Entity, UVec2)],
1124 1125 reservations: &Reservations,
1125 1126 reach: &ColonyReach,
@@ -1146,7 +1147,7 @@ pub fn find_tending(
1146 1147 .map(|(_, pos)| pos.0)
1147 1148 .collect();
1148 1149 let pickup = (!herbs.is_empty())
1149 - .then(|| reach.nearest_unbounded(from, &herbs))
1150 + .then(|| reach.nearest(map, from, SEARCH_TILES, &herbs))
1150 1151 .flatten();
1151 1152 Some(Task::Tend {
1152 1153 patient,
@@ -1634,7 +1635,15 @@ fn choose_jobs(
1634 1635 }),
1635 1636 (WorkType::Build, None, &|| find_refuel(&build, tile.0)),
1636 1637 (WorkType::Doctor, Some(Skill::Medicine), &|| {
1637 - find_tending(&patients, &reservations, &reach, &snapshot, entity, tile.0)
1638 + find_tending(
1639 + &map,
1640 + &patients,
1641 + &reservations,
1642 + &reach,
1643 + &snapshot,
1644 + entity,
1645 + tile.0,
1646 + )
1638 1647 }),
1639 1648 (WorkType::Build, None, &|| {
1640 1649 (dark || chilly)

src/jobs/tests.rs +1 -0

@@ -22,6 +22,7 @@ fn test_app(map: WorldMap) -> App {
22 22 .init_resource::<Reservations>()
23 23 .init_resource::<crate::family::Homes>()
24 24 .init_resource::<crate::pathfinding::ColonyReach>()
25 + .init_resource::<crate::rooms::Rooms>()
25 26 .add_message::<crate::mood::Ate>()
26 27 .init_resource::<crate::designations::Designations>()
27 28 .init_resource::<ColonyGoals>()

src/main.rs +2 -1

@@ -21,6 +21,7 @@ mod paths;
21 21 mod plants;
22 22 mod population;
23 23 mod rng;
24 +mod rooms;
24 25 mod save;
25 26 mod selection;
26 27 mod sim;
@@ -82,6 +83,6 @@ fn main() {
82 83 zones::ZonesPlugin,
83 84 ui::HudPlugin,
84 85 ))
85 - .add_plugins((save::SavePlugin, threats::ThreatsPlugin))
86 + .add_plugins((save::SavePlugin, threats::ThreatsPlugin, rooms::RoomsPlugin))
86 87 .run();
87 88 }

src/mood.rs +12 -22

@@ -4,16 +4,16 @@ use serde::{Deserialize, Serialize};
4 4 use crate::{
5 5 climate::Warmth,
6 6 colonist::Colonist,
7 - family::{Child, Family, Homes, Partner, floored},
7 + family::{Child, Family, Homes, Partner},
8 8 food::{HUNGRY, Hunger},
9 9 items::{Carrying, ItemAssets, ItemKind, ItemQuery, drop_items},
10 10 jobs::Reservations,
11 11 needs::Rest,
12 12 population::ColonyLog,
13 13 rng::SimRng,
14 + rooms::Rooms,
14 15 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 16 skills::Skills,
16 - structures::{Structure, StructureKind},
17 17 threats::{HURT, Health},
18 18 world::{TilePos, WorldMap},
19 19 worldgen::WorldGenerated,
@@ -39,6 +39,7 @@ pub const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
39 39 pub const SICK_TICKS: u64 = 12 * TICKS_PER_HOUR;
40 40 pub const MOOD_HOURS: f32 = 12.0;
41 41 pub const LEAVE_MOOD: f32 = 0.2;
42 +pub const ROOM_MOOD: f32 = 0.3;
42 43 pub const LEAVE_AFTER_HOURS: u32 = 48;
43 44 pub const OLD_AGE: u64 = 60;
44 45 pub const OLD_AGE_DEATH_PER_YEAR: f32 = 0.03;
@@ -111,8 +112,7 @@ pub struct MoodFactors {
111 112 pub home: bool,
112 113 pub grieving: bool,
113 114 pub cold: bool,
114 - pub beauty: bool,
115 - pub floored: bool,
115 + pub room: f32,
116 116 pub hurt: bool,
117 117 }
118 118
@@ -140,12 +140,7 @@ pub fn mood_target(f: MoodFactors) -> f32 {
140 140 if f.cold {
141 141 mood -= 0.15;
142 142 }
143 - if f.beauty {
144 - mood += 0.1;
145 - }
146 - if f.floored {
147 - mood += 0.05;
148 - }
143 + mood += ROOM_MOOD * f.room;
149 144 if f.hurt {
150 145 mood -= 0.1;
151 146 }
@@ -205,7 +200,7 @@ fn update_mood(
205 200 homes: Res<Homes>,
206 201 map: Res<WorldMap>,
207 202 losses: Res<Losses>,
208 - structures: Query<(&TilePos, &Structure), Without<Colonist>>,
203 + rooms: Res<Rooms>,
209 204 mut colonists: Query<(
210 205 Entity,
211 206 &mut Mood,
@@ -223,11 +218,6 @@ fn update_mood(
223 218 return;
224 219 }
225 220 let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS);
226 - let statues: Vec<UVec2> = structures
227 - .iter()
228 - .filter(|(_, s)| s.kind == StructureKind::Statue)
229 - .map(|(pos, _)| pos.0)
230 - .collect();
231 221 for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health) in
232 222 &mut colonists
233 223 {
@@ -237,8 +227,10 @@ fn update_mood(
237 227 }
238 228 let own_home = homes.of(family.copied());
239 229 let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2())));
240 - let beauty = own_home.is_some_and(|h| statues.iter().any(|&t| h.contains(t)));
241 - let floored = own_home.is_some_and(|h| h.interior().all(|t| floored(&map, t)));
230 + let room = own_home
231 + .and_then(|h| h.interior().next())
232 + .and_then(|t| rooms.at(t))
233 + .map_or(0.0, |room| room.quality);
242 234 let target = mood_target(MoodFactors {
243 235 fed: hunger.0,
244 236 rest: rest.0,
@@ -250,8 +242,7 @@ fn update_mood(
250 242 home,
251 243 grieving,
252 244 cold: warmth.cold(),
253 - beauty,
254 - floored,
245 + room,
255 246 hurt: health.0 < HURT,
256 247 });
257 248 mood.0 += (target - mood.0) / MOOD_HOURS;
@@ -361,8 +352,7 @@ mod tests {
361 352 let settled = mood_target(MoodFactors {
362 353 partner: true,
363 354 home: true,
364 - beauty: true,
365 - floored: true,
355 + room: 1.0,
366 356 last_meal_cooked: Some(true),
367 357 ..content()
368 358 });

src/pathfinding.rs +7 -13

@@ -101,17 +101,6 @@ impl ColonyReach {
101 101 self.0.as_ref().is_none_or(|reach| reach.near(tile))
102 102 }
103 103
104 - pub fn nearest_unbounded(&self, from: UVec2, targets: &HashSet<UVec2>) -> Option<UVec2> {
105 - targets
106 - .iter()
107 - .filter(|t| self.allows(**t))
108 - .min_by_key(|t| {
109 - let d = t.as_ivec2() - from.as_ivec2();
110 - (d.length_squared(), t.y, t.x)
111 - })
112 - .copied()
113 - }
114 -
115 104 pub fn nearest(
116 105 &self,
117 106 map: &WorldMap,
@@ -122,7 +111,11 @@ impl ColonyReach {
122 111 match &self.0 {
123 112 Some(reach) if reach.contains(from) => targets
124 113 .iter()
125 - .filter(|t| reach.contains(**t))
114 + .filter(|t| {
115 + let radius = search_reach(max_tiles) as i32;
116 + reach.contains(**t)
117 + && (t.as_ivec2() - from.as_ivec2()).abs().max_element() <= radius
118 + })
126 119 .min_by_key(|t| {
127 120 let d = t.as_ivec2() - from.as_ivec2();
128 121 (d.length_squared(), t.y, t.x)
@@ -336,11 +329,12 @@ mod tests {
336 329 let far = UVec2::new(5, 20);
337 330 let targets = HashSet::from([across, far]);
338 331 assert_eq!(
339 - reach.nearest(&map, UVec2::new(5, 5), 100, &targets),
332 + reach.nearest(&map, UVec2::new(5, 5), 1000, &targets),
340 333 Some(far)
341 334 );
342 335 let elsewhere = UVec2::new(20, 5);
343 336 assert_eq!(reach.nearest(&map, elsewhere, 1000, &targets), Some(across));
337 + assert_eq!(reach.nearest(&map, UVec2::new(5, 5), 100, &targets), None);
344 338 }
345 339
346 340 // Wandering picks random nearby goals; one walled off by a river or cliff made A* search the

src/rooms.rs +380 -0

@@ -0,0 +1,380 @@
1 +use std::collections::{HashMap, HashSet, VecDeque};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + building::{Blueprints, BuildKind, MAX_ROOM_TILES},
7 + colonist::Colonist,
8 + sim::{SimSystems, SimTick},
9 + structures::{Structure, StructureKind},
10 + world::{Terrain, TilePos, WorldMap},
11 + worldgen::WorldGenerated,
12 +};
13 +
14 +pub struct RoomsPlugin;
15 +
16 +impl Plugin for RoomsPlugin {
17 + fn build(&self, app: &mut App) {
18 + app.init_resource::<Rooms>()
19 + .add_systems(Update, clear_rooms.run_if(on_message::<WorldGenerated>))
20 + .add_systems(
21 + FixedUpdate,
22 + (update_rooms, (roof_rooms, remove_bad_doors))
23 + .chain()
24 + .in_set(SimSystems::Decide),
25 + );
26 + }
27 +}
28 +
29 +pub const MIN_ROOM_TILES: usize = 4;
30 +const ROOM_CHECK_TICKS: u64 = 60;
31 +const ROOM_PLAN_TICKS: u64 = 300;
32 +const CROWDED_TILES_PER_BED: usize = 3;
33 +const SIDES: [IVec2; 4] = [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y];
34 +
35 +#[derive(Debug, Clone, PartialEq)]
36 +pub struct Room {
37 + pub tiles: Vec<UVec2>,
38 + pub doors: Vec<UVec2>,
39 + pub quality: f32,
40 +}
41 +
42 +impl Room {
43 + pub fn bounds(&self) -> (UVec2, UVec2) {
44 + let lo = self.tiles.iter().fold(UVec2::MAX, |a, t| a.min(*t));
45 + let hi = self.tiles.iter().fold(UVec2::ZERO, |a, t| a.max(*t));
46 + (lo, hi)
47 + }
48 +
49 + pub fn is_rectangle(&self) -> bool {
50 + let (lo, hi) = self.bounds();
51 + (hi - lo + UVec2::ONE).element_product() as usize == self.tiles.len()
52 + }
53 +}
54 +
55 +#[derive(Resource, Default, Debug)]
56 +pub struct Rooms {
57 + pub rooms: Vec<Room>,
58 + by_tile: HashMap<UVec2, usize>,
59 + shape: Option<(u64, Vec<UVec2>)>,
60 +}
61 +
62 +impl Rooms {
63 + pub fn at(&self, tile: UVec2) -> Option<&Room> {
64 + self.by_tile.get(&tile).map(|&i| &self.rooms[i])
65 + }
66 +
67 + pub fn contains(&self, tile: UVec2) -> bool {
68 + self.by_tile.contains_key(&tile)
69 + }
70 +
71 + pub fn detect(map: &WorldMap, doors: &HashSet<UVec2>) -> Self {
72 + let detected = detect_rooms(map, doors);
73 + Self {
74 + by_tile: detected
75 + .iter()
76 + .enumerate()
77 + .flat_map(|(i, (tiles, _))| tiles.iter().map(move |t| (*t, i)))
78 + .collect(),
79 + rooms: detected
80 + .into_iter()
81 + .map(|(tiles, doors)| Room {
82 + tiles,
83 + doors,
84 + quality: 0.0,
85 + })
86 + .collect(),
87 + shape: None,
88 + }
89 + }
90 +}
91 +
92 +fn clear_rooms(mut rooms: ResMut<Rooms>) {
93 + *rooms = Rooms::default();
94 +}
95 +
96 +pub fn blocks_room(map: &WorldMap, doors: &HashSet<UVec2>, tile: IVec2) -> bool {
97 + !map.in_bounds(tile)
98 + || map.is_wall(tile)
99 + || doors.contains(&tile.as_uvec2())
100 + || map.get(tile).is_none_or(|t| t.move_cost().is_none())
101 +}
102 +
103 +pub fn detect_rooms(map: &WorldMap, doors: &HashSet<UVec2>) -> Vec<(Vec<UVec2>, Vec<UVec2>)> {
104 + let width = map.size.x;
105 + let index = |p: UVec2| (p.y * width + p.x) as usize;
106 + let mut seen = vec![false; map.size.element_product() as usize];
107 + let mut rooms = Vec::new();
108 + for start in map.positions() {
109 + if seen[index(start)] || blocks_room(map, doors, start.as_ivec2()) {
110 + continue;
111 + }
112 + seen[index(start)] = true;
113 + let mut region = vec![start];
114 + let mut room_doors = HashSet::new();
115 + let mut outdoors = false;
116 + let mut queue = VecDeque::from([start]);
117 + while let Some(tile) = queue.pop_front() {
118 + for side in SIDES {
119 + let next = tile.as_ivec2() + side;
120 + if !map.in_bounds(next) {
121 + outdoors = true;
122 + continue;
123 + }
124 + let next_u = next.as_uvec2();
125 + if doors.contains(&next_u) {
126 + room_doors.insert(next_u);
127 + }
128 + if blocks_room(map, doors, next) || seen[index(next_u)] {
129 + continue;
130 + }
131 + seen[index(next_u)] = true;
132 + region.push(next_u);
133 + queue.push_back(next_u);
134 + }
135 + }
136 + let size = region.len();
137 + if !outdoors && size >= MIN_ROOM_TILES && size <= MAX_ROOM_TILES && !room_doors.is_empty() {
138 + region.sort_by_key(|t| (t.y, t.x));
139 + let mut room_doors: Vec<UVec2> = room_doors.into_iter().collect();
140 + room_doors.sort_by_key(|t| (t.y, t.x));
141 + rooms.push((region, room_doors));
142 + }
143 + }
144 + rooms
145 +}
146 +
147 +#[derive(Debug, Default, Clone, Copy)]
148 +pub struct Furnishing {
149 + pub beds: usize,
150 + pub statue: bool,
151 + pub hearth: bool,
152 +}
153 +
154 +pub fn room_quality(map: &WorldMap, tiles: &[UVec2], furnishing: Furnishing) -> f32 {
155 + let n = tiles.len().max(1) as f32;
156 + let share =
157 + |test: &dyn Fn(UVec2) -> bool| tiles.iter().filter(|t| test(**t)).count() as f32 / n;
158 + let floor = share(&|t| map.get(t.as_ivec2()) == Some(Terrain::WoodFloor));
159 + let roofed = share(&|t| map.is_roofed(t.as_ivec2()));
160 + let space = (n / 16.0).min(1.0);
161 + let crowded = furnishing.beds * CROWDED_TILES_PER_BED > tiles.len();
162 + let quality = 0.1
163 + + 0.2 * space
164 + + 0.25 * floor
165 + + 0.15 * roofed
166 + + if furnishing.statue { 0.2 } else { 0.0 }
167 + + if furnishing.hearth { 0.1 } else { 0.0 }
168 + - if crowded { 0.2 } else { 0.0 };
169 + quality.clamp(0.0, 1.0)
170 +}
171 +
172 +#[allow(clippy::type_complexity)]
173 +fn update_rooms(
174 + tick: Res<SimTick>,
175 + map: Res<WorldMap>,
176 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
177 + mut rooms: ResMut<Rooms>,
178 +) {
179 + if !tick.0.is_multiple_of(ROOM_CHECK_TICKS) {
180 + return;
181 + }
182 + let mut doors: Vec<UVec2> = structures
183 + .iter()
184 + .filter(|(_, s)| s.kind == StructureKind::Door)
185 + .map(|(pos, _)| pos.0)
186 + .collect();
187 + doors.sort_by_key(|t| (t.y, t.x));
188 + let shape = (map.shape_version(), doors.clone());
189 + if rooms.shape.as_ref() != Some(&shape) {
190 + let door_set: HashSet<UVec2> = doors.into_iter().collect();
191 + *rooms = Rooms::detect(&map, &door_set);
192 + rooms.shape = Some(shape);
193 + } else if !tick.0.is_multiple_of(ROOM_PLAN_TICKS) {
194 + return;
195 + }
196 + let mut furnishing = vec![Furnishing::default(); rooms.rooms.len()];
197 + for (pos, structure) in &structures {
198 + let Some(&i) = rooms.by_tile.get(&pos.0) else {
199 + continue;
200 + };
201 + match structure.kind {
202 + StructureKind::Bed => furnishing[i].beds += 1,
203 + StructureKind::Statue => furnishing[i].statue = true,
204 + StructureKind::Hearth => furnishing[i].hearth = true,
205 + _ => {}
206 + }
207 + }
208 + for (room, furnishing) in rooms.rooms.iter_mut().zip(furnishing) {
209 + room.quality = room_quality(&map, &room.tiles, furnishing);
210 + }
211 +}
212 +
213 +fn roof_rooms(
214 + tick: Res<SimTick>,
215 + map: Res<WorldMap>,
216 + rooms: Res<Rooms>,
217 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
218 + mut blueprints: ResMut<Blueprints>,
219 +) {
220 + if !tick.0.is_multiple_of(ROOM_PLAN_TICKS) {
221 + return;
222 + }
223 + let lived_in: HashSet<*const Room> = structures
224 + .iter()
225 + .filter(|(_, s)| matches!(s.kind, StructureKind::Bed | StructureKind::Hearth))
226 + .filter_map(|(pos, _)| rooms.at(pos.0))
227 + .map(|room| room as *const Room)
228 + .collect();
229 + for room in &rooms.rooms {
230 + if !lived_in.contains(&(room as *const Room)) {
231 + continue;
232 + }
233 + for &tile in &room.tiles {
234 + if !map.is_roofed(tile.as_ivec2()) && blueprints.get(tile).is_none() {
235 + blueprints.0.insert(tile, BuildKind::Roof);
236 + }
237 + }
238 + }
239 +}
240 +
241 +pub fn bad_door(map: &WorldMap, door: UVec2) -> bool {
242 + let blocked = SIDES
243 + .iter()
244 + .filter(|side| {
245 + let next = door.as_ivec2() + **side;
246 + !map.in_bounds(next) || map.move_cost(next).is_none()
247 + })
248 + .count();
249 + blocked >= 3
250 +}
251 +
252 +fn remove_bad_doors(
253 + tick: Res<SimTick>,
254 + map: Res<WorldMap>,
255 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
256 + mut blueprints: ResMut<Blueprints>,
257 +) {
258 + if !tick.0.is_multiple_of(ROOM_PLAN_TICKS) {
259 + return;
260 + }
261 + for (pos, structure) in &structures {
262 + if structure.kind == StructureKind::Door
263 + && bad_door(&map, pos.0)
264 + && blueprints.get(pos.0).is_none()
265 + {
266 + blueprints.0.insert(pos.0, BuildKind::Deconstruct);
267 + }
268 + }
269 +}
270 +
271 +#[cfg(test)]
272 +mod tests {
273 + use super::*;
274 +
275 + fn walled(map: &mut WorldMap, lo: UVec2, hi: UVec2) {
276 + for y in lo.y..=hi.y {
277 + for x in lo.x..=hi.x {
278 + if x == lo.x || x == hi.x || y == lo.y || y == hi.y {
279 + map.set_wall(UVec2::new(x, y), true);
280 + }
281 + }
282 + }
283 + }
284 +
285 + // Rooms come from the map, not from plans: any enclosed space with a door that is big enough
286 + // and small enough to roof counts, however and in whatever order it was built, and two rooms
287 + // can share a wall.
288 + #[test]
289 + fn rooms_are_enclosed_spaces_with_a_door() {
290 + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
291 + walled(&mut map, UVec2::new(2, 2), UVec2::new(6, 5));
292 + walled(&mut map, UVec2::new(6, 2), UVec2::new(10, 5));
293 + walled(&mut map, UVec2::new(15, 15), UVec2::new(18, 18));
294 + let doors = [UVec2::new(4, 2), UVec2::new(8, 2)];
295 + for door in doors {
296 + map.set_wall(door, false);
297 + }
298 + let rooms = detect_rooms(&map, &doors.into_iter().collect());
299 + assert_eq!(rooms.len(), 2);
300 + assert!(
301 + rooms
302 + .iter()
303 + .all(|(tiles, doors)| tiles.len() == 6 && doors.len() == 1)
304 + );
305 + }
306 +
307 + #[test]
308 + fn quality_rewards_floors_furniture_and_space() {
309 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
310 + let tiles: Vec<UVec2> = (1..5)
311 + .flat_map(|y| (1..5).map(move |x| UVec2::new(x, y)))
312 + .collect();
313 + let bare = room_quality(&map, &tiles, Furnishing::default());
314 + for &t in &tiles {
315 + map.set(t, Terrain::WoodFloor);
316 + map.set_roof(t, true);
317 + }
318 + let furnished = Furnishing {
319 + beds: 2,
320 + statue: true,
321 + hearth: true,
322 + };
323 + let nice = room_quality(&map, &tiles, furnished);
324 + assert!(nice > bare + 0.5, "{bare} {nice}");
325 + let packed = Furnishing {
326 + beds: 6,
327 + ..furnished
328 + };
329 + assert!(room_quality(&map, &tiles, packed) < nice);
330 + }
331 +
332 + // A door needs walls on two opposite sides and open ground on the others to be passable; one
333 + // boxed in by walls on three sides leads nowhere and gets taken down.
334 + #[test]
335 + fn doors_walled_in_on_three_sides_are_bad() {
336 + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass);
337 + map.set_wall(UVec2::new(4, 5), true);
338 + map.set_wall(UVec2::new(6, 5), true);
339 + assert!(!bad_door(&map, UVec2::new(5, 5)));
340 + map.set_wall(UVec2::new(5, 6), true);
341 + assert!(bad_door(&map, UVec2::new(5, 5)));
342 + }
343 +
344 + // A room built by hand, wall by wall, gets a roof once it has a bed in it; a door boxed in
345 + // on three sides gets taken down.
346 + #[test]
347 + fn lived_in_rooms_are_roofed_and_bad_doors_removed() {
348 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
349 + walled(&mut map, UVec2::new(2, 2), UVec2::new(6, 5));
350 + let door = UVec2::new(4, 2);
351 + map.set_wall(door, false);
352 + let boxed = UVec2::new(12, 12);
353 + for side in [IVec2::X, IVec2::NEG_X, IVec2::Y] {
354 + map.set_wall((boxed.as_ivec2() + side).as_uvec2(), true);
355 + }
356 + let mut app = App::new();
357 + app.insert_resource(map)
358 + .init_resource::<SimTick>()
359 + .init_resource::<Rooms>()
360 + .init_resource::<Blueprints>()
361 + .add_systems(Update, (update_rooms, roof_rooms, remove_bad_doors).chain());
362 + let structure = |kind| Structure { kind };
363 + let world = app.world_mut();
364 + world.spawn((structure(StructureKind::Door), TilePos(door)));
365 + world.spawn((structure(StructureKind::Door), TilePos(boxed)));
366 + world.spawn((structure(StructureKind::Bed), TilePos(UVec2::new(3, 3))));
367 + app.update();
368 + let blueprints = app.world().resource::<Blueprints>();
369 + let roofs = blueprints
370 + .0
371 + .values()
372 + .filter(|k| **k == BuildKind::Roof)
373 + .count();
374 + assert_eq!(roofs, 6);
375 + assert_eq!(blueprints.get(boxed), Some(BuildKind::Deconstruct));
376 + assert_eq!(blueprints.get(door), None);
377 + let rooms = app.world().resource::<Rooms>();
378 + assert!(rooms.at(UVec2::new(3, 3)).is_some_and(|r| r.quality > 0.0));
379 + }
380 +}

src/selection.rs +22 -3

@@ -17,6 +17,7 @@ use crate::{
17 17 mood::{Age, Mood, Sick},
18 18 needs::Rest,
19 19 population::ColonyLog,
20 + rooms::Rooms,
20 21 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
21 22 skills::Skills,
22 23 structures::{Fuel, Structure, StructureKind},
@@ -268,7 +269,12 @@ fn update_tile_info(
268 269 goals: Res<ColonyGoals>,
269 270 log: Res<ColonyLog>,
270 271 zones: Res<Zones>,
271 - tools: (Res<ZoneTool>, Res<BuildTool>, Res<DesignateTool>),
272 + tools: (
273 + Res<ZoneTool>,
274 + Res<BuildTool>,
275 + Res<DesignateTool>,
276 + Res<Rooms>,
277 + ),
272 278 clock: (
273 279 Res<Time<Virtual>>,
274 280 Res<SimTick>,
@@ -391,11 +397,25 @@ fn update_tile_info(
391 397 .join(" ")
392 398 };
393 399 let materials = format!("{}\n{}", stock_line(false), stock_line(true));
400 + let (tool, build_tool, designate_tool, rooms) = tools;
401 + let hovered_room = cursor
402 + .tile
403 + .and_then(|tile| rooms.at(tile))
404 + .map(|room| {
405 + format!(
406 + ", room ({} tiles, quality {:.0}%)",
407 + room.tiles.len(),
408 + room.quality * 100.0
409 + )
410 + })
411 + .unwrap_or_default();
394 412 let hovered_zone = match cursor.tile {
395 413 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
396 414 Some(tile) if zones.is_farm(tile) => ", Farm",
397 415 _ => "",
398 - };
416 + }
417 + .to_string()
418 + + &hovered_room;
399 419 let hovered_items: String = items
400 420 .iter()
401 421 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
@@ -425,7 +445,6 @@ fn update_tile_info(
425 445 )
426 446 .collect();
427 447 let events: String = log.0.iter().map(|line| format!("\n{line}")).collect();
428 - let (tool, build_tool, designate_tool) = tools;
429 448 let tool_hint = match (tool.mode, build_tool.mode) {
430 449 _ if designate_tool.active => {
431 450 "\nCHOP/MINE: drag over trees and rock, shift-drag to clear".to_string()

tasks.md +1 -0

@@ -52,6 +52,7 @@
52 52 - [x] Family hearths: every home gets a hearth where the family cooks (meals stay home) and which heats the home; growth needs 3 days of food per colonist; failed food searches back off
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 +- [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
55 56 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
56 57
57 58 ## Planned