Add family hearths; tie growth to stored food; back off failed food searches

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 14:35 UTC
commit
944828980207162e7270402048c1ef9f865c5f64
parent
28fbce3a75
tree
browse at this commit

10 files changed +206 -39

CLAUDE.md +4 -2

@@ -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`), 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, 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.
@@ -52,6 +52,8 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
52 52 - `src/skills.rs` `Skills{xp: [f32; 8]}` (required on Colonist, default = level 5 everywhere so tests keep old speeds; `assign_age` gives adults `Skills::random` (levels 1–7 + one talent at 10), children level 0). `Skill::{Mining, Woodcutting, Construction, Farming, Cooking, Hunting, Crafting, Taming}`, `Skill::of(task)`; level = √(xp/600) ≤ 20; `speed(level)` = 0.5 + 0.1·level. run_jobs: rate (after light/sick/tool) × speed via `WorkCarry` fractional accumulator; +1 xp per tick the task's `Task::progress()` advances. choose_jobs candidates are `(WorkType, Option<Skill>, finder)`; candidates whose skill is < POOR_LEVEL 3 run after all others (cooking, crafting, blueprints, bed sites, hunting, taming, plain sowing; urgent food/fire work has no skill). Selection shows top 3 skills; saved in ColonistSave (serde default).
53 53 - `src/threats.rs` wolves and health. `AnimalKind::Wolf` (`is_predator`, population 3, `migration_ticks` 24 h vs 2 h, not tameable, meat 12) spawns with `Predator{target, fed_until, cooldown}`. `Health(f32)` required on Colonist and Animal. `prowl` (Decide, every 30 ticks): unfed wolves `pick_prey` (nearest within PROWL_RANGE 24, colonists cost +COLONIST_DETOUR 6) and path adjacent; `bite` (Act): adjacent, cooldown 60 → colonist −0.25 (log on first bite; death drops load, releases, Losses, log), animal −0.5 (despawn); a kill feeds the wolf FED_TICKS 12 h. `heal` (every 60 ticks): 0.3/day, ×3 asleep in a bed. Wild prey flees wolves too (wander threats include predators); wolves skip flee/wander while targeting. choose_jobs: `nearest_wolf`; a wolf within DANGER_RANGE 6 makes colonists ready and interrupts autonomous jobs (except wolf hunts/MoveTo); fighters (adult, health ≥ INJURED 0.5, Food work allowed, not exhausted/hungry) Hunt a reachable (`ColonyReach::allows_near`) unreserved wolf in danger range or within DEFEND_RANGE 15 of colony home (first candidate); others in danger flee (`flee_from`, 10 tiles away). Mood −0.1 below HURT 0.75; selection shows health; saved in ColonistSave.
54 54 - 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.
55 +- 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.
56 +- 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.
55 57 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
56 58
57 59 ## Performance notes
@@ -63,7 +65,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
63 65 - Fixed schedules (FixedFirst..FixedLast) use `SingleThreadedExecutor`: with hundreds of tiny ticks per frame the multi-threaded executor spent most time parking/handing off between threads (5.6× faster natively; wasm was already single-threaded).
64 66 - A* (`astar_limited`) reuses thread-local buffers with generation stamps (no 800 KB clear per call); `find_nearby_path(.., WANDER_SEARCH 800)` caps wander searches. Failed wander attempts (animals, colonists) still restart the idle timer and failed flees wait FLEE_RETRY_SECS — retrying every tick for boxed-in animals made late games ~15× slower per tick.
65 67 - plan_homes caches its reach flood by (`shape_version`, colony home, size); `WorldMap::set` bumps `shape_version` when walkability changes. `GatherMemo` also memoises forage (food mask) and haul failures (HAUL_RETRY_TICKS 120, reset when Zones change — checked before choose_jobs' early return). choose_jobs builds `occupied` lazily; run_jobs only collects mover positions while someone builds a wall.
66 -- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. Uncapped, that save grows to ~40–46 colonists (births vs old age at 60) at ~36 µs per tick, ~12 game days per 10 s.
68 +- 20-colonist save natively: ~17–20 µs per tick, ~23 game days per 10 s. Uncapped with hearths, that save settles at ~70–90 colonists (births vs old age at 60, growth food rule) at ~75 µs per tick, ~6 game days per 10 s.
67 69 - `ColonyReach::nearest(map, from, max_tiles, targets)`: when `from` is inside the colony reach, picks the nearest reachable target by distance (no flood); else falls back to `nearest_of`. Used by find_haul, find_storage (orders pass an empty reach), material_source, input_source. Cooking/toolmaking check stations before searching inputs; find_blueprint_work caches material lookups per call, has a reach precheck and a failure memo (reset when Blueprints change). Stock totals are one pass over items; the starving food-tile set is built lazily.
68 70 - `ColonyLog::push` also logs at debug level; run benchmarks with `RUST_LOG=info,simulator=debug` to see colony events.
69 71 - Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time).

src/building.rs +4 -2

@@ -68,7 +68,7 @@ impl BuildKind {
68 68 BuildKind::Roof => (ItemKind::Wood, 1),
69 69 BuildKind::Pave => (ItemKind::Stone, 1),
70 70 BuildKind::Floor => (ItemKind::Wood, 1),
71 - BuildKind::Refuel(kind) => kind.cost(),
71 + BuildKind::Refuel(kind) => kind.refuel_cost(),
72 72 BuildKind::Deconstruct => (ItemKind::Wood, 0),
73 73 }
74 74 }
@@ -82,6 +82,7 @@ impl BuildKind {
82 82 BuildKind::Structure(StructureKind::Door) => 300,
83 83 BuildKind::Structure(StructureKind::Workbench) => 600,
84 84 BuildKind::Structure(StructureKind::Statue) => 900,
85 + BuildKind::Structure(StructureKind::Hearth) => 400,
85 86 BuildKind::Roof => 120,
86 87 BuildKind::Pave => 120,
87 88 BuildKind::Floor => 90,
@@ -315,7 +316,7 @@ pub enum BuildMode {
315 316 Deconstruct,
316 317 }
317 318
318 -pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [
319 +pub const BUILD_TOOL_CYCLE: [BuildMode; 10] = [
319 320 BuildMode::Place(StructureKind::Wall),
320 321 BuildMode::Place(StructureKind::Door),
321 322 BuildMode::Place(StructureKind::Campfire),
@@ -323,6 +324,7 @@ pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [
323 324 BuildMode::Place(StructureKind::Bed),
324 325 BuildMode::Place(StructureKind::Workbench),
325 326 BuildMode::Place(StructureKind::Statue),
327 + BuildMode::Place(StructureKind::Hearth),
326 328 BuildMode::Floor,
327 329 BuildMode::Deconstruct,
328 330 ];

src/family.rs +19 -5

@@ -9,7 +9,7 @@ use crate::{
9 9 economy::in_colony,
10 10 items::{Item, ItemAssets},
11 11 pathfinding::{ColonyReach, Reach, nearest_tile},
12 - population::{ColonyLog, MAX_COLONISTS, food_in_store},
12 + population::{ColonyLog, GROWTH_FOOD_DAYS, MAX_COLONISTS, food_in_store},
13 13 rng::SimRng,
14 14 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
15 15 structures::{Structure, StructureKind},
@@ -128,6 +128,10 @@ impl Home {
128 128 self.ring().filter(move |t| !home.is_corner(*t))
129 129 }
130 130
131 + pub fn hearth_tile(&self) -> UVec2 {
132 + self.hi - UVec2::ONE
133 + }
134 +
131 135 pub fn statue_tile(&self) -> UVec2 {
132 136 UVec2::new(self.hi.x - 1, self.lo.y + 1)
133 137 }
@@ -469,6 +473,7 @@ fn plan_homes(
469 473 };
470 474 let doors = of_kind(StructureKind::Door);
471 475 let statues = of_kind(StructureKind::Statue);
476 + let hearths = of_kind(StructureKind::Hearth);
472 477 let beds = of_kind(StructureKind::Bed);
473 478 for &bed in &beds {
474 479 if !homes.is_home(bed)
@@ -532,6 +537,10 @@ fn plan_homes(
532 537 blueprints.0.insert(tile, BuildKind::Roof);
533 538 }
534 539 if built && owned.contains(home) {
540 + if !hearths.iter().any(|&t| home.contains(t)) {
541 + let hearth = BuildKind::Structure(StructureKind::Hearth);
542 + blueprints.0.insert(home.hearth_tile(), hearth);
543 + }
535 544 if !statues.iter().any(|&t| home.contains(t)) {
536 545 let statue = BuildKind::Structure(StructureKind::Statue);
537 546 blueprints.0.insert(home.statue_tile(), statue);
@@ -621,7 +630,7 @@ fn births(
621 630 .filter(|(pos, s)| s.kind == StructureKind::Bed && home.contains(pos.0))
622 631 .count();
623 632 let built = home.interior().all(|t| map.is_roofed(t.as_ivec2()));
624 - let room = count + born < MAX_COLONISTS && food >= (count + born) as f32;
633 + let room = count + born < MAX_COLONISTS && food >= (count + born) as f32 * GROWTH_FOOD_DAYS;
625 634 if parents.len() < 2 || children >= MAX_CHILDREN || beds < 2 || !built || !room {
626 635 continue;
627 636 }
@@ -810,8 +819,9 @@ mod tests {
810 819 }
811 820
812 821 // Gold has one use: a statue in each finished home, which lifts the family's mood. Planning
813 - // it is what makes the colony want gold at all. Finished homes also get a wooden floor (a
814 - // smaller mood boost), built under beds and statues alike.
822 + // it is what makes the colony want gold at all. Finished homes also get a hearth, where the
823 + // family cooks (one campfire can't feed a big colony) and which heats the home, and a wooden
824 + // floor (a smaller mood boost), built under beds, statue and hearth alike.
815 825 #[test]
816 826 fn finished_homes_get_a_statue_and_floor() {
817 827 let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
@@ -852,7 +862,11 @@ mod tests {
852 862 .interior()
853 863 .filter(|t| blueprints.get(*t) == Some(BuildKind::Floor))
854 864 .count();
855 - assert_eq!(floors, home.interior().count() - 1);
865 + assert_eq!(floors, home.interior().count() - 2);
866 + assert_eq!(
867 + blueprints.get(home.hearth_tile()),
868 + Some(BuildKind::Structure(StructureKind::Hearth))
869 + );
856 870 }
857 871
858 872 fn walled(map: &mut WorldMap, home: Home, door: UVec2) -> HashSet<UVec2> {

src/food.rs +24 -5

@@ -8,7 +8,7 @@ use crate::{
8 8 items::{Carrying, Item, ItemKind},
9 9 jobs::{CurrentJob, Reservations, Target},
10 10 needs::is_sleeping,
11 - pathfinding::nearest_of,
11 + pathfinding::ColonyReach,
12 12 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
13 13 world::{TilePos, WorldMap},
14 14 };
@@ -76,6 +76,7 @@ pub fn portion(kind: ItemKind, fed: f32) -> u32 {
76 76 pub fn find_food(
77 77 map: &WorldMap,
78 78 reservations: &Reservations,
79 + reach: &ColonyReach,
79 80 items: &[(Item, TilePos)],
80 81 from: UVec2,
81 82 ) -> Option<UVec2> {
@@ -86,7 +87,7 @@ pub fn find_food(
86 87 .map(|(_, pos)| pos.0)
87 88 .collect()
88 89 };
89 - let search = |tiles: HashSet<UVec2>| nearest_of(map, from, FOOD_SEARCH_TILES, &tiles);
90 + let search = |tiles: HashSet<UVec2>| reach.nearest(map, from, FOOD_SEARCH_TILES, &tiles);
90 91 search(tiles(|k| k == ItemKind::Meal)).or_else(|| search(tiles(ItemKind::is_raw_food)))
91 92 }
92 93
@@ -216,15 +217,33 @@ mod tests {
216 217 food(ItemKind::Wood, 0),
217 218 ];
218 219 assert_eq!(
219 - find_food(&map, &reservations, &items, UVec2::ZERO),
220 + find_food(
221 + &map,
222 + &reservations,
223 + &ColonyReach::default(),
224 + &items,
225 + UVec2::ZERO
226 + ),
220 227 Some(UVec2::new(15, 0))
221 228 );
222 229 assert_eq!(
223 - find_food(&map, &reservations, &items[..1], UVec2::ZERO),
230 + find_food(
231 + &map,
232 + &reservations,
233 + &ColonyReach::default(),
234 + &items[..1],
235 + UVec2::ZERO
236 + ),
224 237 Some(UVec2::new(1, 0))
225 238 );
226 239 assert_eq!(
227 - find_food(&map, &reservations, &items[2..], UVec2::ZERO),
240 + find_food(
241 + &map,
242 + &reservations,
243 + &ColonyReach::default(),
244 + &items[2..],
245 + UVec2::ZERO
246 + ),
228 247 None
229 248 );
230 249 }

src/jobs/mod.rs +55 -20

@@ -14,7 +14,7 @@ use crate::{
14 14 colonist::{Colonist, Path, repath},
15 15 designations::Designations,
16 16 economy::{ColonyGoals, in_colony},
17 - family::{Child, Family, Homes},
17 + family::{Child, Family, Home, Homes},
18 18 food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food},
19 19 items::{
20 20 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, STACK_LIMIT, drop_items,
@@ -209,10 +209,10 @@ pub enum Recipe {
209 209 }
210 210
211 211 impl Recipe {
212 - pub fn station(self) -> StructureKind {
212 + pub fn stations(self) -> &'static [StructureKind] {
213 213 match self {
214 - Recipe::Meal => StructureKind::Campfire,
215 - Recipe::Tools => StructureKind::Workbench,
214 + Recipe::Meal => &[StructureKind::Campfire, StructureKind::Hearth],
215 + Recipe::Tools => &[StructureKind::Workbench],
216 216 }
217 217 }
218 218
@@ -529,6 +529,7 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
529 529
530 530 const GATHER_RETRY_TICKS: u64 = 300;
531 531 const HAUL_RETRY_TICKS: u64 = 120;
532 +const FOOD_RETRY_TICKS: u64 = 60;
532 533
533 534 #[derive(Default, Clone, Copy)]
534 535 pub struct Memo {
@@ -548,6 +549,7 @@ pub struct GatherMemo {
548 549 forage: Memo,
549 550 haul: Memo,
550 551 build: Memo,
552 + food: HashMap<Entity, u64>,
551 553 }
552 554
553 555 pub fn find_work(
@@ -639,6 +641,7 @@ pub fn find_haul(
639 641 zones: &Zones,
640 642 reservations: &Reservations,
641 643 reach: &ColonyReach,
644 + homes: &Homes,
642 645 items: &[(Item, TilePos)],
643 646 from: UVec2,
644 647 ) -> Option<Task> {
@@ -662,6 +665,7 @@ pub fn find_haul(
662 665 .filter(|(item, pos)| {
663 666 reservations.is_free(Target::Pickup(pos.0))
664 667 && reach.allows(pos.0)
668 + && !(item.kind == ItemKind::Meal && homes.is_home(pos.0))
665 669 && misplaced(item, pos.0, &mut best)
666 670 && *room.entry(item.kind).or_insert_with(|| {
667 671 storage_candidates(zones, reservations, &stacks, item.kind)
@@ -1023,21 +1027,34 @@ pub fn tool_source(
1023 1027 nearest_of(map, from, SEARCH_TILES, &tools)
1024 1028 }
1025 1029
1026 -pub fn find_crafting(ctx: &BuildContext, from: UVec2, recipe: Recipe) -> Option<Task> {
1030 +pub fn find_crafting(
1031 + ctx: &BuildContext,
1032 + from: UVec2,
1033 + recipe: Recipe,
1034 + home: Option<&Home>,
1035 +) -> Option<Task> {
1036 + let usable = |pos: UVec2, kind: StructureKind| {
1037 + kind != StructureKind::Hearth || home.is_some_and(|h| h.contains(pos))
1038 + };
1027 1039 let station = ctx
1028 1040 .structures
1029 1041 .iter()
1030 1042 .filter(|&&(pos, kind, fuel)| {
1031 - kind == recipe.station()
1043 + recipe.stations().contains(&kind)
1044 + && usable(pos, kind)
1032 1045 && fuel >= COOK_MIN_FUEL
1033 1046 && ctx.reservations.is_free(Target::Station(pos))
1034 1047 })
1035 - .map(|&(pos, ..)| pos)
1036 - .min_by(|a, b| {
1037 - a.as_vec2()
1038 - .distance_squared(from.as_vec2())
1039 - .total_cmp(&b.as_vec2().distance_squared(from.as_vec2()))
1040 - })?;
1048 + .min_by_key(|&&(pos, kind, _)| {
1049 + let d = pos.as_ivec2() - from.as_ivec2();
1050 + (
1051 + kind != StructureKind::Hearth,
1052 + d.length_squared(),
1053 + pos.y,
1054 + pos.x,
1055 + )
1056 + })
1057 + .map(|&(pos, ..)| pos)?;
1041 1058 let pickup = input_source(ctx, from, recipe)?;
1042 1059 Some(Task::Craft {
1043 1060 recipe,
@@ -1120,7 +1137,8 @@ pub fn find_refuel(ctx: &BuildContext, from: UVec2) -> Option<Task> {
1120 1137 .filter(|&&(pos, kind, fuel)| {
1121 1138 kind.fuel_ticks()
1122 1139 .is_some_and(|full| fuel < full / REFUEL_BELOW_SHARE)
1123 - && pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS * 2.0
1140 + && (kind == StructureKind::Hearth
1141 + || pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS * 2.0)
1124 1142 && ctx.reservations.is_free(Target::Site(pos))
1125 1143 })
1126 1144 .min_by_key(|(.., fuel)| *fuel)?;
@@ -1260,6 +1278,7 @@ fn choose_jobs(
1260 1278 if blueprints.is_changed() {
1261 1279 gather_memo.build = Memo::default();
1262 1280 }
1281 + gather_memo.food.retain(|_, until| tick.0 < *until);
1263 1282 let wolves: Vec<(Entity, UVec2)> = animals
1264 1283 .iter()
1265 1284 .filter(|(_, _, animal, _)| animal.kind.is_predator())
@@ -1351,7 +1370,11 @@ fn choose_jobs(
1351 1370 ) in &mut colonists
1352 1371 {
1353 1372 let exhausted = rest.0 < EXHAUSTED_REST;
1354 - let starving = hunger.0 < STARVING && {
1373 + let skip_food = gather_memo
1374 + .food
1375 + .get(&entity)
1376 + .is_some_and(|&until| tick.0 < until);
1377 + let starving = hunger.0 < STARVING && !skip_food && {
1355 1378 let tiles = food_tiles.get_or_insert_with(|| {
1356 1379 items
1357 1380 .iter()
@@ -1400,9 +1423,12 @@ fn choose_jobs(
1400 1423 family: family.copied(),
1401 1424 child,
1402 1425 };
1403 - let food = wants_food(hunger.0)
1404 - .then(|| find_food(&map, &reservations, &snapshot, tile.0))
1426 + let food = (wants_food(hunger.0) && !skip_food)
1427 + .then(|| find_food(&map, &reservations, &reach, &snapshot, tile.0))
1405 1428 .flatten();
1429 + if wants_food(hunger.0) && !skip_food && food.is_none() {
1430 + gather_memo.food.insert(entity, tick.0 + FOOD_RETRY_TICKS);
1431 + }
1406 1432 let fighter = !child && health.0 >= INJURED && priorities.allows(WorkType::Food);
1407 1433 let free_to_fight = fighter && !exhausted && !wants_food(hunger.0);
1408 1434 let defend =
@@ -1488,11 +1514,12 @@ fn choose_jobs(
1488 1514 occupied,
1489 1515 };
1490 1516 let has_input = |recipe| input_source(&build, tile.0, recipe).is_some();
1517 + let own_home = homes.of(family.copied());
1491 1518 let cooking = || {
1492 1519 if !wanted(ItemKind::Meal) {
1493 1520 return None;
1494 1521 }
1495 - find_crafting(&build, tile.0, Recipe::Meal).or_else(|| {
1522 + find_crafting(&build, tile.0, Recipe::Meal, own_home).or_else(|| {
1496 1523 (build.campfire_needed(zones.home(tile.0)) && has_input(Recipe::Meal))
1497 1524 .then(|| find_campfire(&build, tile.0))
1498 1525 .flatten()
@@ -1502,7 +1529,7 @@ fn choose_jobs(
1502 1529 if !wanted(ItemKind::Tools) {
1503 1530 return None;
1504 1531 }
1505 - find_crafting(&build, tile.0, Recipe::Tools).or_else(|| {
1532 + find_crafting(&build, tile.0, Recipe::Tools, None).or_else(|| {
1506 1533 (build.count(StructureKind::Workbench) == 0 && has_input(Recipe::Tools))
1507 1534 .then(|| find_workbench(&build, tile.0))
1508 1535 .flatten()
@@ -1603,7 +1630,15 @@ fn choose_jobs(
1603 1630 if skip_haul {
1604 1631 return None;
1605 1632 }
1606 - let haul = find_haul(&map, &zones, &reservations, &reach, &snapshot, tile.0);
1633 + let haul = find_haul(
1634 + &map,
1635 + &zones,
1636 + &reservations,
1637 + &reach,
1638 + &homes,
1639 + &snapshot,
1640 + tile.0,
1641 + );
1607 1642 haul_failed.set(haul.is_none());
1608 1643 haul
1609 1644 }),
@@ -1814,7 +1849,7 @@ fn run_jobs(
1814 1849 let input = recipe.input();
1815 1850 let fire = structures
1816 1851 .iter()
1817 - .any(|(_, pos, s, _)| pos.0 == station && s.kind == recipe.station());
1852 + .any(|(_, pos, s, _)| pos.0 == station && recipe.stations().contains(&s.kind));
1818 1853 let ours = reservations.holder(Target::Station(station)) == Some(entity);
1819 1854 if !fire || !ours {
1820 1855 Step::Done

src/jobs/tests.rs +57 -0

@@ -1554,3 +1554,60 @@ fn adults_fight_wolves_and_children_flee() {
1554 1554 let route = &app.world().get::<Path>(child).unwrap().0;
1555 1555 assert!(route.back().is_some_and(|end| end.x > 26));
1556 1556 }
1557 +
1558 +// Families cook at the hearth in their own home (another family's hearth is off limits, even if
1559 +// it is closer), and the meals they make stay in the home instead of being hauled away.
1560 +#[test]
1561 +fn families_cook_at_their_own_hearth_and_keep_the_meals() {
1562 + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass));
1563 + set_goals(&mut app, &[(ItemKind::Meal, 5)]);
1564 + let own = crate::family::Home::at(UVec2::new(15, 3));
1565 + let other = crate::family::Home::at(UVec2::new(3, 3));
1566 + app.world_mut()
1567 + .resource_mut::<crate::family::Homes>()
1568 + .by_family
1569 + .extend([(1, own), (2, other)]);
1570 + for home in [own, other] {
1571 + app.world_mut().spawn((
1572 + Structure {
1573 + kind: StructureKind::Hearth,
1574 + },
1575 + TilePos(home.hearth_tile()),
1576 + ));
1577 + }
1578 + app.world_mut()
1579 + .spawn((Item::new(ItemKind::Berries, 20), TilePos(UVec2::new(9, 9))));
1580 + let cook = spawn_colonist(&mut app, UVec2::new(9, 6));
1581 + app.world_mut()
1582 + .entity_mut(cook)
1583 + .insert(crate::family::Family(1));
1584 + let mut first = None;
1585 + for _ in 0..600 {
1586 + run_ticks(&mut app, 1);
1587 + if let Some(job) = job(&app, cook)
1588 + && !matches!(job.task, Task::ExpandStockpile { .. } | Task::Haul { .. })
1589 + {
1590 + first = Some(job.task);
1591 + break;
1592 + }
1593 + }
1594 + assert!(
1595 + matches!(first, Some(Task::Craft { station, .. }) if station == own.hearth_tile()),
1596 + "{first:?}"
1597 + );
1598 + let meal_home = UVec2::new(16, 4);
1599 + app.world_mut()
1600 + .spawn((Item::new(ItemKind::Meal, 1), TilePos(meal_home)));
1601 + let snapshot: Vec<(Item, TilePos)> = vec![(Item::new(ItemKind::Meal, 1), TilePos(meal_home))];
1602 + let world = app.world();
1603 + let haul = find_haul(
1604 + world.resource::<WorldMap>(),
1605 + world.resource::<Zones>(),
1606 + world.resource::<Reservations>(),
1607 + &crate::pathfinding::ColonyReach::default(),
1608 + world.resource::<crate::family::Homes>(),
1609 + &snapshot,
1610 + UVec2::new(9, 6),
1611 + );
1612 + assert_eq!(haul, None);
1613 +}

src/population.rs +7 -4

@@ -34,6 +34,7 @@ impl Plugin for PopulationPlugin {
34 34
35 35 pub const STARVATION_TICKS: u32 = 24 * TICKS_PER_HOUR as u32;
36 36 pub const MAX_COLONISTS: usize = usize::MAX;
37 +pub const GROWTH_FOOD_DAYS: f32 = 3.0;
37 38 pub const ARRIVAL_CHANCE: f32 = 0.6;
38 39 const ARRIVAL_ATTEMPTS: usize = 10;
39 40 const ARRIVAL_HOUR_TICK: u64 = 4 * TICKS_PER_HOUR;
@@ -101,7 +102,7 @@ pub fn food_in_store<'a>(items: impl Iterator<Item = &'a Item>) -> f32 {
101 102
102 103 pub fn prosperous(colonists: usize, food: f32, free_beds: usize, wood: u32) -> bool {
103 104 colonists < MAX_COLONISTS
104 - && food >= colonists.max(1) as f32
105 + && food >= colonists.max(1) as f32 * GROWTH_FOOD_DAYS
105 106 && (free_beds > 0 || wood >= StructureKind::Bed.cost().1)
106 107 }
107 108
@@ -193,11 +194,13 @@ mod tests {
193 194 use super::*;
194 195 use crate::world::Terrain;
195 196
197 + // Growth needs a few days of food in store per colonist. With only a day's worth, plentiful
198 + // cooking at family hearths let births boom to 120 colonists, followed by famine.
196 199 #[test]
197 200 fn colonies_grow_only_when_they_can_feed_and_house_newcomers() {
198 - assert!(prosperous(3, 3.5, 1, 0));
199 - assert!(prosperous(3, 3.5, 0, 20));
200 - assert!(!prosperous(3, 2.0, 1, 50));
201 + assert!(prosperous(3, 9.5, 1, 0));
202 + assert!(prosperous(3, 9.5, 0, 20));
203 + assert!(!prosperous(3, 5.0, 1, 50));
201 204 assert!(!prosperous(3, 10.0, 0, 5));
202 205 assert!(!prosperous(MAX_COLONISTS, 100.0, 5, 100));
203 206 }

src/selection.rs +5 -1

@@ -634,7 +634,11 @@ pub fn describe_colonist(view: &ColonistView) -> String {
634 634 _,
635 635 ) => "fetching ingredients".to_string(),
636 636 (Some(Task::Craft { recipe, .. }), n) if n > 0 => {
637 - format!("carrying ingredients to the {}", recipe.station().name())
637 + let station = match recipe {
638 + Recipe::Meal => "fire",
639 + Recipe::Tools => "workbench",
640 + };
641 + format!("carrying ingredients to the {station}")
638 642 }
639 643 (
640 644 Some(

src/structures.rs +30 -0

@@ -33,6 +33,7 @@ pub enum StructureKind {
33 33 Door,
34 34 Workbench,
35 35 Statue,
36 + Hearth,
36 37 }
37 38
38 39 impl StructureKind {
@@ -45,6 +46,14 @@ impl StructureKind {
45 46 StructureKind::Door => (ItemKind::Wood, 5),
46 47 StructureKind::Workbench => (ItemKind::Wood, 20),
47 48 StructureKind::Statue => (ItemKind::Gold, 5),
49 + StructureKind::Hearth => (ItemKind::Stone, 6),
50 + }
51 + }
52 +
53 + pub fn refuel_cost(self) -> (ItemKind, u32) {
54 + match self {
55 + StructureKind::Hearth => (ItemKind::Wood, 4),
56 + _ => self.cost(),
48 57 }
49 58 }
50 59
@@ -52,6 +61,7 @@ impl StructureKind {
52 61 let hours = match self {
53 62 StructureKind::Campfire => 12,
54 63 StructureKind::Torch => 8,
64 + StructureKind::Hearth => 24,
55 65 _ => return None,
56 66 };
57 67 Some(hours * TICKS_PER_HOUR as u32)
@@ -61,6 +71,7 @@ impl StructureKind {
61 71 match self {
62 72 StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))),
63 73 StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))),
74 + StructureKind::Hearth => Some((3.5, Vec3::new(1.0, 0.75, 0.45))),
64 75 _ => None,
65 76 }
66 77 }
@@ -69,6 +80,7 @@ impl StructureKind {
69 80 match self {
70 81 StructureKind::Campfire => 20.0,
71 82 StructureKind::Torch => 4.0,
83 + StructureKind::Hearth => 22.0,
72 84 _ => 0.0,
73 85 }
74 86 }
@@ -86,6 +98,7 @@ impl StructureKind {
86 98 StructureKind::Door => "door",
87 99 StructureKind::Workbench => "workbench",
88 100 StructureKind::Statue => "statue",
101 + StructureKind::Hearth => "hearth",
89 102 }
90 103 }
91 104 }
@@ -137,6 +150,7 @@ pub struct StructureAssets {
137 150 bench_leg: Part,
138 151 anvil: Part,
139 152 plinth: Part,
153 + hearth_mouth: Part,
140 154 gold: Part,
141 155 }
142 156
@@ -230,6 +244,10 @@ fn create_structure_assets(
230 244 Rectangle::new(TILE_SIZE * 0.32, TILE_SIZE * 0.18).into(),
231 245 Color::srgb_u8(70, 74, 82),
232 246 ),
247 + hearth_mouth: part(
248 + Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.5).into(),
249 + Color::srgb_u8(40, 30, 26),
250 + ),
233 251 plinth: part(
234 252 Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.22).into(),
235 253 Color::srgb_u8(150, 146, 140),
@@ -394,6 +412,18 @@ pub fn spawn_structure(
394 412 c.spawn(part_bundle(&assets.bench, at(0.0, 0.05, 0.01)));
395 413 c.spawn(part_bundle(&assets.anvil, at(0.15, 0.2, 0.02)));
396 414 }),
415 + StructureKind::Hearth => entity.with_children(|c| {
416 + c.spawn(part_bundle(&assets.wall, at(0.0, 0.0, 0.0)));
417 + c.spawn(part_bundle(&assets.hearth_mouth, at(0.0, -0.1, 0.01)));
418 + c.spawn(part_bundle(
419 + &assets.flame,
420 + at(0.0, -0.08, 0.02).with_scale(Vec3::splat(0.7)),
421 + ));
422 + c.spawn(part_bundle(
423 + &assets.flame_core,
424 + at(0.0, -0.12, 0.03).with_scale(Vec3::splat(0.7)),
425 + ));
426 + }),
397 427 StructureKind::Statue => entity.with_children(|c| {
398 428 c.spawn(part_bundle(&assets.plinth, at(0.0, -0.35, 0.0)));
399 429 c.spawn(part_bundle(&assets.gold, at(0.0, 0.05, 0.01)));

tasks.md +1 -0

@@ -49,6 +49,7 @@
49 49 - [x] Wolves: predators hunt animals and colonists; health, bites, healing; adults fight wolves near the colony, children and the hurt flee; newcomers now walk to the colony instead of stranding at the edge
50 50 - [x] Optimization pass: single-threaded fixed schedules, A* scratch buffers, capped wander searches, no retry-every-tick for boxed-in animals, cached reach, more search memos; native SIM_BENCH mode
51 51 - [x] Colonist cap removed (numbered names past the list); job search pass for 40+ colonists (reach-based nearest targets, cheaper cooking/blueprint checks)
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
52 53 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
53 54
54 55 ## Planned