Add families with their own homes and children

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 21:55 UTC
commit
fac8d7efb5cfed93102cec39b33a20a08b32d399
parent
dae07f6ccc
tree
browse at this commit

9 files changed +760 -10

CLAUDE.md +1 -0

@@ -37,6 +37,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
37 37 - `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.
38 38 - `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.
39 39 - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (≤ MAX_COLONISTS, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home.
40 +- `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): drops homes of extinct families; roofs finished homes (`roofs_needed`: interior enclosed exactly); plans one home per partnered family without one via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt free of stockpile/farm/blueprints/homes) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes. Selection text shows partner / child days left.
40 41 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
41 42
42 43 ## Gotchas

src/building.rs +7 -1

@@ -4,6 +4,7 @@ use bevy::prelude::*;
4 4
5 5 use crate::{
6 6 colonist::Colonist,
7 + family::Homes,
7 8 items::{Item, ItemKind},
8 9 sim::{SimSystems, SimTick},
9 10 structures::{Structure, StructureKind},
@@ -238,6 +239,7 @@ pub fn plan_shelter(
238 239 structures: Query<(&TilePos, &Structure), Without<Colonist>>,
239 240 items: Query<&TilePos, With<Item>>,
240 241 mut blueprints: ResMut<Blueprints>,
242 + homes: Res<Homes>,
241 243 ) {
242 244 let shelter_pending = blueprints.0.values().any(|k| *k != BuildKind::Pave);
243 245 if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || shelter_pending {
@@ -247,7 +249,11 @@ pub fn plan_shelter(
247 249 structures.iter().map(|(pos, s)| (pos.0, s.kind)).collect();
248 250 let beds: Vec<UVec2> = structures
249 251 .iter()
250 - .filter(|(pos, kind)| *kind == StructureKind::Bed && !map.is_roofed(pos.as_ivec2()))
252 + .filter(|(pos, kind)| {
253 + *kind == StructureKind::Bed
254 + && !map.is_roofed(pos.as_ivec2())
255 + && homes.owner(*pos).is_none()
256 + })
251 257 .map(|(pos, _)| *pos)
252 258 .collect();
253 259 if beds.is_empty() {

src/family.rs +625 -0

@@ -0,0 +1,625 @@
1 +use std::collections::{HashMap, HashSet};
2 +
3 +use bevy::prelude::*;
4 +
5 +use crate::{
6 + building::{Blueprints, BuildKind, enclosed_room},
7 + colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist},
8 + economy::in_colony,
9 + items::{Item, ItemAssets},
10 + pathfinding::nearest_tile,
11 + population::{ColonyLog, MAX_COLONISTS, food_in_store},
12 + rng::SimRng,
13 + sim::{SimSystems, SimTick, TICKS_PER_HOUR},
14 + structures::{Structure, StructureKind},
15 + world::{TilePos, WorldMap},
16 + worldgen::WorldGenerated,
17 + zones::{Zones, can_zone},
18 +};
19 +
20 +pub struct FamilyPlugin;
21 +
22 +impl Plugin for FamilyPlugin {
23 + fn build(&self, app: &mut App) {
24 + app.init_resource::<Homes>()
25 + .init_resource::<Affinity>()
26 + .add_systems(Update, clear_families.run_if(on_message::<WorldGenerated>))
27 + .add_systems(
28 + FixedUpdate,
29 + (
30 + plan_homes.in_set(SimSystems::Decide),
31 + (bond, births, grow_up).chain().in_set(SimSystems::Act),
32 + ),
33 + );
34 + }
35 +}
36 +
37 +pub const HOME_INTERIOR: UVec2 = UVec2::new(4, 3);
38 +pub const HOME_MIN_DISTANCE: i32 = 6;
39 +pub const BOND_RANGE: i32 = 4;
40 +pub const BOND_HOURS: u32 = 16;
41 +pub const BOND_CHANCE: f32 = 0.2;
42 +pub const MAX_CHILDREN: usize = 2;
43 +pub const BIRTH_CHANCE: f32 = 0.35;
44 +pub const CHILDHOOD_TICKS: u64 = 4 * 24 * TICKS_PER_HOUR;
45 +pub const CHILD_SCALE: f32 = 0.6;
46 +const BIRTH_HOUR_TICK: u64 = 20 * TICKS_PER_HOUR;
47 +const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR;
48 +const HOME_PLAN_INTERVAL_TICKS: u64 = 300;
49 +const HOME_SEARCH_TILES: usize = 6000;
50 +const HOME_CLEARANCE: i32 = 1;
51 +
52 +#[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Hash)]
53 +pub struct Family(pub u32);
54 +
55 +#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
56 +pub struct Partner(pub Entity);
57 +
58 +#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
59 +pub struct Child {
60 + pub born: u64,
61 +}
62 +
63 +impl Child {
64 + pub fn days_left(&self, tick: SimTick) -> u64 {
65 + (self.born + CHILDHOOD_TICKS)
66 + .saturating_sub(tick.0)
67 + .div_ceil(DAY_TICKS)
68 + }
69 +}
70 +
71 +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72 +pub struct Home {
73 + pub lo: UVec2,
74 + pub hi: UVec2,
75 +}
76 +
77 +impl Home {
78 + pub fn at(lo: UVec2) -> Self {
79 + Self {
80 + lo,
81 + hi: lo + HOME_INTERIOR + UVec2::ONE,
82 + }
83 + }
84 +
85 + pub fn contains(&self, tile: UVec2) -> bool {
86 + tile.cmpge(self.lo).all() && tile.cmple(self.hi).all()
87 + }
88 +
89 + pub fn interior(&self) -> impl Iterator<Item = UVec2> + use<> {
90 + let (lo, hi) = (self.lo + UVec2::ONE, self.hi - UVec2::ONE);
91 + (lo.y..=hi.y).flat_map(move |y| (lo.x..=hi.x).map(move |x| UVec2::new(x, y)))
92 + }
93 +
94 + pub fn ring(&self) -> impl Iterator<Item = UVec2> + use<> {
95 + let (lo, hi) = (self.lo, self.hi);
96 + (lo.y..=hi.y)
97 + .flat_map(move |y| (lo.x..=hi.x).map(move |x| UVec2::new(x, y)))
98 + .filter(move |t| t.x == lo.x || t.x == hi.x || t.y == lo.y || t.y == hi.y)
99 + }
100 +
101 + pub fn bed_tiles(&self) -> [UVec2; 2] {
102 + let corner = UVec2::new(self.lo.x + 1, self.hi.y - 1);
103 + [corner, corner + UVec2::X]
104 + }
105 +
106 + pub fn blueprints(&self, toward: UVec2) -> HashMap<UVec2, BuildKind> {
107 + let corner = |t: &UVec2| {
108 + (t.x == self.lo.x || t.x == self.hi.x) && (t.y == self.lo.y || t.y == self.hi.y)
109 + };
110 + let door = self
111 + .ring()
112 + .filter(|t| !corner(t))
113 + .min_by(|a, b| {
114 + a.as_vec2()
115 + .distance_squared(toward.as_vec2())
116 + .total_cmp(&b.as_vec2().distance_squared(toward.as_vec2()))
117 + .then((a.y, a.x).cmp(&(b.y, b.x)))
118 + })
119 + .unwrap_or(self.lo);
120 + let mut plan: HashMap<UVec2, BuildKind> = self
121 + .ring()
122 + .map(|t| (t, BuildKind::Structure(StructureKind::Wall)))
123 + .collect();
124 + plan.insert(door, BuildKind::Structure(StructureKind::Door));
125 + for bed in self.bed_tiles() {
126 + plan.insert(bed, BuildKind::Structure(StructureKind::Bed));
127 + }
128 + plan
129 + }
130 +}
131 +
132 +#[derive(Resource, Default, Debug)]
133 +pub struct Homes {
134 + pub by_family: HashMap<u32, Home>,
135 + next_family: u32,
136 +}
137 +
138 +impl Homes {
139 + pub fn new_family(&mut self) -> Family {
140 + self.next_family += 1;
141 + Family(self.next_family)
142 + }
143 +
144 + pub fn owner(&self, tile: UVec2) -> Option<u32> {
145 + self.by_family
146 + .iter()
147 + .find(|(_, home)| home.contains(tile))
148 + .map(|(family, _)| *family)
149 + }
150 +
151 + pub fn of(&self, family: Option<Family>) -> Option<&Home> {
152 + self.by_family.get(&family?.0)
153 + }
154 +}
155 +
156 +#[derive(Resource, Default, Debug)]
157 +pub struct Affinity(pub HashMap<(Entity, Entity), u32>);
158 +
159 +fn clear_families(mut homes: ResMut<Homes>, mut affinity: ResMut<Affinity>) {
160 + homes.by_family.clear();
161 + affinity.0.clear();
162 +}
163 +
164 +#[derive(Clone, Copy)]
165 +pub struct Single {
166 + pub entity: Entity,
167 + pub tile: UVec2,
168 + pub family: Option<Family>,
169 +}
170 +
171 +pub fn bond_hour(
172 + affinity: &mut Affinity,
173 + singles: &[Single],
174 + rng: &mut SimRng,
175 +) -> Vec<(Entity, Entity)> {
176 + let mut bonded: Vec<(Entity, Entity)> = Vec::new();
177 + let taken =
178 + |bonded: &[(Entity, Entity)], e: Entity| bonded.iter().any(|&(a, b)| a == e || b == e);
179 + for (i, a) in singles.iter().enumerate() {
180 + for b in &singles[i + 1..] {
181 + let near = (a.tile.as_ivec2() - b.tile.as_ivec2()).abs().max_element() <= BOND_RANGE;
182 + let kin = a.family.is_some() && a.family == b.family;
183 + if !near || kin {
184 + continue;
185 + }
186 + let key = (a.entity.min(b.entity), a.entity.max(b.entity));
187 + let hours = affinity.0.entry(key).or_insert(0);
188 + *hours += 1;
189 + if *hours >= BOND_HOURS
190 + && !taken(&bonded, a.entity)
191 + && !taken(&bonded, b.entity)
192 + && rng.range_f32(0.0..1.0) < BOND_CHANCE
193 + {
194 + bonded.push(key);
195 + }
196 + }
197 + }
198 + for pair in &bonded {
199 + affinity
200 + .0
201 + .retain(|&(a, b), _| ![a, b].iter().any(|e| *e == pair.0 || *e == pair.1));
202 + }
203 + bonded
204 +}
205 +
206 +#[allow(clippy::type_complexity)]
207 +fn bond(
208 + mut commands: Commands,
209 + tick: Res<SimTick>,
210 + mut rng: ResMut<SimRng>,
211 + mut homes: ResMut<Homes>,
212 + mut affinity: ResMut<Affinity>,
213 + mut log: ResMut<ColonyLog>,
214 + colonists: Query<(
215 + Entity,
216 + &Colonist,
217 + &TilePos,
218 + Option<&Family>,
219 + Option<&Partner>,
220 + Has<Child>,
221 + )>,
222 +) {
223 + if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
224 + return;
225 + }
226 + for (entity, _, _, _, partner, _) in &colonists {
227 + if let Some(partner) = partner
228 + && !colonists.contains(partner.0)
229 + {
230 + commands.entity(entity).remove::<Partner>();
231 + }
232 + }
233 + let singles: Vec<Single> = colonists
234 + .iter()
235 + .filter(|(.., partner, child)| partner.is_none() && !child)
236 + .map(|(entity, _, tile, family, ..)| Single {
237 + entity,
238 + tile: tile.0,
239 + family: family.copied(),
240 + })
241 + .collect();
242 + for (a, b) in bond_hour(&mut affinity, &singles, &mut rng) {
243 + let family = homes.new_family();
244 + commands.entity(a).insert((Partner(b), family));
245 + commands.entity(b).insert((Partner(a), family));
246 + let name = |e: Entity| {
247 + colonists
248 + .get(e)
249 + .map(|c| c.1.name.clone())
250 + .unwrap_or_default()
251 + };
252 + log.push(
253 + *tick,
254 + format!("{} and {} became partners", name(a), name(b)),
255 + );
256 + }
257 +}
258 +
259 +pub struct HomeContext<'a> {
260 + pub map: &'a WorldMap,
261 + pub zones: &'a Zones,
262 + pub blueprints: &'a Blueprints,
263 + pub homes: &'a Homes,
264 + pub occupied: &'a HashSet<UVec2>,
265 +}
266 +
267 +impl HomeContext<'_> {
268 + fn claimed(&self, tile: UVec2) -> bool {
269 + self.zones.is_stockpile(tile)
270 + || self.zones.is_farm(tile)
271 + || self.blueprints.get(tile).is_some()
272 + || self.homes.owner(tile).is_some()
273 + }
274 +
275 + fn open(&self, tile: IVec2) -> bool {
276 + self.map.in_bounds(tile)
277 + && can_zone(self.map, tile)
278 + && self.map.plant(tile).is_none()
279 + && !self.map.is_wall(tile)
280 + && !self.occupied.contains(&tile.as_uvec2())
281 + && !self.claimed(tile.as_uvec2())
282 + }
283 +
284 + fn fits(&self, home: Home) -> bool {
285 + let (lo, hi) = (home.lo.as_ivec2(), home.hi.as_ivec2());
286 + let (outer_lo, outer_hi) = (lo - HOME_CLEARANCE, hi + HOME_CLEARANCE);
287 + (outer_lo.y..=outer_hi.y).all(|y| {
288 + (outer_lo.x..=outer_hi.x).all(|x| {
289 + let tile = IVec2::new(x, y);
290 + let core = tile.cmpge(lo).all() && tile.cmple(hi).all();
291 + if core {
292 + self.open(tile)
293 + } else {
294 + !self.map.in_bounds(tile) || !self.claimed(tile.as_uvec2())
295 + }
296 + })
297 + })
298 + }
299 +
300 + pub fn plan(&self, colony_home: UVec2) -> Option<Home> {
301 + let center = (HOME_INTERIOR / 2 + UVec2::ONE).as_ivec2();
302 + let mut found = None;
303 + nearest_tile(self.map, colony_home, HOME_SEARCH_TILES, |p| {
304 + let lo = p.as_ivec2() - center;
305 + if lo.min_element() < 0 {
306 + return false;
307 + }
308 + let home = Home::at(lo.as_uvec2());
309 + let middle = (home.lo + home.hi).as_ivec2() / 2;
310 + if (middle - colony_home.as_ivec2()).abs().max_element() < HOME_MIN_DISTANCE {
311 + return false;
312 + }
313 + found = self.fits(home).then_some(home);
314 + found.is_some()
315 + })?;
316 + found
317 + }
318 +}
319 +
320 +pub fn roofs_needed(map: &WorldMap, doors: &HashSet<UVec2>, home: &Home) -> Vec<UVec2> {
321 + let interior: Vec<UVec2> = home.interior().collect();
322 + let Some(room) = enclosed_room(map, doors, interior[0]) else {
323 + return Vec::new();
324 + };
325 + if room.len() != interior.len() {
326 + return Vec::new();
327 + }
328 + interior
329 + .into_iter()
330 + .filter(|t| !map.is_roofed(t.as_ivec2()))
331 + .collect()
332 +}
333 +
334 +#[allow(clippy::too_many_arguments)]
335 +fn plan_homes(
336 + tick: Res<SimTick>,
337 + map: Res<WorldMap>,
338 + zones: Res<Zones>,
339 + mut homes: ResMut<Homes>,
340 + mut blueprints: ResMut<Blueprints>,
341 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
342 + items: Query<&TilePos, With<Item>>,
343 + families: Query<(&Family, Has<Partner>), With<Colonist>>,
344 +) {
345 + if !tick.0.is_multiple_of(HOME_PLAN_INTERVAL_TICKS) {
346 + return;
347 + }
348 + let living: HashSet<u32> = families.iter().map(|(f, _)| f.0).collect();
349 + homes.by_family.retain(|family, _| living.contains(family));
350 + let doors: HashSet<UVec2> = structures
351 + .iter()
352 + .filter(|(_, s)| s.kind == StructureKind::Door)
353 + .map(|(pos, _)| pos.0)
354 + .collect();
355 + for home in homes.by_family.values() {
356 + if blueprints.0.keys().any(|t| home.contains(*t)) {
357 + continue;
358 + }
359 + for tile in roofs_needed(&map, &doors, home) {
360 + blueprints.0.insert(tile, BuildKind::Roof);
361 + }
362 + }
363 + let Some(family) = families
364 + .iter()
365 + .filter(|(f, partnered)| *partnered && !homes.by_family.contains_key(&f.0))
366 + .map(|(f, _)| f.0)
367 + .min()
368 + else {
369 + return;
370 + };
371 + let occupied: HashSet<UVec2> = structures
372 + .iter()
373 + .map(|(pos, _)| pos.0)
374 + .chain(items.iter().map(|pos| pos.0))
375 + .collect();
376 + let colony_home = zones.home(map.size / 2);
377 + let ctx = HomeContext {
378 + map: &map,
379 + zones: &zones,
380 + blueprints: &blueprints,
381 + homes: &homes,
382 + occupied: &occupied,
383 + };
384 + if let Some(home) = ctx.plan(colony_home) {
385 + blueprints.0.extend(home.blueprints(colony_home));
386 + homes.by_family.insert(family, home);
387 + }
388 +}
389 +
390 +#[allow(clippy::too_many_arguments, clippy::type_complexity)]
391 +fn births(
392 + mut commands: Commands,
393 + tick: Res<SimTick>,
394 + map: Res<WorldMap>,
395 + zones: Res<Zones>,
396 + homes: Res<Homes>,
397 + assets: Res<ColonistAssets>,
398 + item_assets: Res<ItemAssets>,
399 + mut materials: ResMut<Assets<ColorMaterial>>,
400 + mut rng: ResMut<SimRng>,
401 + mut log: ResMut<ColonyLog>,
402 + items: Query<(&Item, &TilePos)>,
403 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
404 + colonists: Query<(&Colonist, Option<&Family>, Has<Partner>, Has<Child>)>,
405 +) {
406 + if tick.0 % DAY_TICKS != BIRTH_HOUR_TICK {
407 + return;
408 + }
409 + let count = colonists.iter().count();
410 + let colony_home = zones.home(map.size / 2);
411 + let food = food_in_store(
412 + items
413 + .iter()
414 + .filter(|(_, pos)| in_colony(colony_home, pos.0))
415 + .map(|(item, _)| item),
416 + );
417 + let mut born = 0;
418 + let mut families: Vec<(&u32, &Home)> = homes.by_family.iter().collect();
419 + families.sort_by_key(|(family, _)| **family);
420 + for (&family, home) in families {
421 + let members = || {
422 + colonists
423 + .iter()
424 + .filter(move |c| c.1 == Some(&Family(family)))
425 + };
426 + let parents: Vec<&str> = members()
427 + .filter(|c| c.2)
428 + .map(|c| c.0.name.as_str())
429 + .collect();
430 + let children = members().filter(|c| c.3).count();
431 + let beds = structures
432 + .iter()
433 + .filter(|(pos, s)| s.kind == StructureKind::Bed && home.contains(pos.0))
434 + .count();
435 + let built = home.interior().all(|t| map.is_roofed(t.as_ivec2()));
436 + let room = count + born < MAX_COLONISTS && food >= (count + born) as f32;
437 + if parents.len() < 2 || children >= MAX_CHILDREN || beds < 2 || !built || !room {
438 + continue;
439 + }
440 + if rng.range_f32(0.0..1.0) >= BIRTH_CHANCE {
441 + continue;
442 + }
443 + let taken: Vec<&str> = colonists.iter().map(|c| c.0.name.as_str()).collect();
444 + let index = (0..NAMES.len())
445 + .find(|&i| !taken.contains(&NAMES[i]))
446 + .unwrap_or(count);
447 + let tile = home
448 + .interior()
449 + .find(|t| !home.bed_tiles().contains(t))
450 + .unwrap_or(home.lo);
451 + let child = spawn_colonist(
452 + &mut commands,
453 + &assets,
454 + &item_assets,
455 + &mut materials,
456 + index,
457 + tile,
458 + );
459 + commands.entity(child).insert((
460 + Child { born: tick.0 },
461 + Family(family),
462 + Transform::from_translation(WorldMap::tile_center(tile).extend(1.0))
463 + .with_scale(Vec3::splat(CHILD_SCALE)),
464 + ));
465 + born += 1;
466 + log.push(
467 + *tick,
468 + format!(
469 + "{} and {} had a child, {}",
470 + parents[0],
471 + parents[1],
472 + NAMES[index % NAMES.len()]
473 + ),
474 + );
475 + }
476 +}
477 +
478 +fn grow_up(
479 + mut commands: Commands,
480 + tick: Res<SimTick>,
481 + mut log: ResMut<ColonyLog>,
482 + mut children: Query<(Entity, &Colonist, &Child, &mut Transform)>,
483 +) {
484 + if !tick.0.is_multiple_of(TICKS_PER_HOUR) {
485 + return;
486 + }
487 + for (entity, colonist, child, mut transform) in &mut children {
488 + if tick.0 < child.born + CHILDHOOD_TICKS {
489 + continue;
490 + }
491 + transform.scale = Vec3::ONE;
492 + commands.entity(entity).remove::<Child>();
493 + log.push(*tick, format!("{} has grown up", colonist.name));
494 + }
495 +}
496 +
497 +#[cfg(test)]
498 +mod tests {
499 + use super::*;
500 + use crate::world::Terrain;
501 +
502 + fn singles(app_world: &mut World, tiles: &[UVec2]) -> Vec<Single> {
503 + tiles
504 + .iter()
505 + .map(|&tile| Single {
506 + entity: app_world.spawn_empty().id(),
507 + tile,
508 + family: None,
509 + })
510 + .collect()
511 + }
512 +
513 + fn bond_for(hours: u32, singles: &[Single]) -> Vec<(Entity, Entity)> {
514 + let mut affinity = Affinity::default();
515 + let mut rng = SimRng::new(3);
516 + let mut bonded: Vec<(Entity, Entity)> = Vec::new();
517 + for _ in 0..hours {
518 + let free: Vec<Single> = singles
519 + .iter()
520 + .filter(|s| !bonded.iter().any(|&(a, b)| a == s.entity || b == s.entity))
521 + .copied()
522 + .collect();
523 + bonded.extend(bond_hour(&mut affinity, &free, &mut rng));
524 + }
525 + bonded
526 + }
527 +
528 + #[test]
529 + fn colonists_who_spend_time_together_become_partners() {
530 + let mut world = World::new();
531 + let near = singles(&mut world, &[UVec2::new(5, 5), UVec2::new(6, 7)]);
532 + assert!(bond_for(BOND_HOURS - 1, &near).is_empty());
533 + assert_eq!(bond_for(BOND_HOURS * 4, &near).len(), 1);
534 + let apart = singles(&mut world, &[UVec2::new(5, 5), UVec2::new(15, 5)]);
535 + assert!(bond_for(BOND_HOURS * 4, &apart).is_empty());
536 + }
537 +
538 + #[test]
539 + fn everyone_has_at_most_one_partner_and_family_never_pairs() {
540 + let mut world = World::new();
541 + let mut trio = singles(&mut world, &[UVec2::ONE, UVec2::ONE, UVec2::ONE]);
542 + let bonded = bond_for(BOND_HOURS * 10, &trio);
543 + assert_eq!(bonded.len(), 1);
544 + for single in &mut trio {
545 + single.family = Some(Family(1));
546 + }
547 + assert!(bond_for(BOND_HOURS * 10, &trio).is_empty());
548 + }
549 +
550 + fn plan_on(map: &WorldMap, zones: &Zones, occupied: &HashSet<UVec2>) -> Home {
551 + HomeContext {
552 + map,
553 + zones,
554 + blueprints: &Blueprints::default(),
555 + homes: &Homes::default(),
556 + occupied,
557 + }
558 + .plan(UVec2::new(20, 20))
559 + .unwrap()
560 + }
561 +
562 + #[test]
563 + fn homes_go_on_open_ground_away_from_the_colony_center() {
564 + let mut map = WorldMap::filled(UVec2::splat(40), Terrain::Grass);
565 + for x in 0..40 {
566 + map.set_plant(UVec2::new(x, 14), Some(crate::world::Plant::Tree));
567 + }
568 + let mut zones = Zones::default();
569 + zones.add_tiles(&map, [UVec2::new(20, 20), UVec2::new(21, 20)]);
570 + let occupied = HashSet::from([UVec2::new(20, 26)]);
571 + let home = plan_on(&map, &zones, &occupied);
572 + let center = (home.lo + home.hi).as_ivec2() / 2;
573 + assert!((center - IVec2::new(20, 20)).abs().max_element() >= HOME_MIN_DISTANCE);
574 + for y in home.lo.y - 1..=home.hi.y + 1 {
575 + for x in home.lo.x - 1..=home.hi.x + 1 {
576 + let tile = UVec2::new(x, y);
577 + assert!(!zones.is_stockpile(tile));
578 + if home.contains(tile) {
579 + assert!(map.plant(tile.as_ivec2()).is_none());
580 + assert!(!occupied.contains(&tile));
581 + }
582 + }
583 + }
584 + }
585 +
586 + #[test]
587 + fn a_home_is_a_walled_room_with_a_door_and_two_beds() {
588 + let home = Home::at(UVec2::new(10, 10));
589 + let plan = home.blueprints(UVec2::new(12, 0));
590 + let count = |kind: StructureKind| {
591 + plan.values()
592 + .filter(|k| **k == BuildKind::Structure(kind))
593 + .count()
594 + };
595 + let ring = 2 * (HOME_INTERIOR.x + 2) + 2 * HOME_INTERIOR.y;
596 + assert_eq!(
597 + count(StructureKind::Wall) + count(StructureKind::Door),
598 + ring as usize
599 + );
600 + assert_eq!(count(StructureKind::Door), 1);
601 + assert_eq!(count(StructureKind::Bed), 2);
602 + let door = plan
603 + .iter()
604 + .find(|(_, k)| **k == BuildKind::Structure(StructureKind::Door))
605 + .unwrap()
606 + .0;
607 + assert_eq!(door.y, home.lo.y);
608 + for bed in home.bed_tiles() {
609 + assert!(home.interior().any(|t| t == bed));
610 + }
611 + }
612 +
613 + #[test]
614 + fn finished_homes_get_a_roof() {
615 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
616 + let home = Home::at(UVec2::new(5, 5));
617 + let door = UVec2::new(7, 5);
618 + for tile in home.ring().filter(|t| *t != door) {
619 + map.set_wall(tile, true);
620 + }
621 + assert!(roofs_needed(&map, &HashSet::new(), &home).is_empty());
622 + let roofs = roofs_needed(&map, &HashSet::from([door]), &home);
623 + assert_eq!(roofs.len(), (HOME_INTERIOR.x * HOME_INTERIOR.y) as usize);
624 + }
625 +}

src/jobs/mod.rs +14 -3

@@ -11,6 +11,7 @@ use crate::{
11 11 building::{Blueprints, BuildKind},
12 12 colonist::{Colonist, Path, repath},
13 13 economy::{ColonyGoals, in_colony},
14 + family::{Child, Family, Homes},
14 15 food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food},
15 16 items::{
16 17 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
@@ -777,15 +778,18 @@ fn choose_jobs(
777 778 &mut Carrying,
778 779 &Rest,
779 780 &Hunger,
781 + Option<&Family>,
782 + Has<Child>,
780 783 ),
781 784 With<Colonist>,
782 785 >,
786 + homes: Res<Homes>,
783 787 ) {
784 788 let wildlife: Vec<(Entity, UVec2, AnimalKind)> = animals
785 789 .iter()
786 790 .map(|(e, pos, a)| (e, pos.0, a.kind))
787 791 .collect();
788 - let colonist_count = colonists.iter().count();
792 + let adults = colonists.iter().filter(|c| !c.9).count();
789 793 let structures_list: Vec<(UVec2, StructureKind, u32)> = structures
790 794 .iter()
791 795 .map(|(pos, s, fuel)| (pos.0, s.kind, fuel.map_or(u32::MAX, |f| f.0)))
@@ -829,7 +833,9 @@ fn choose_jobs(
829 833 .map(|(_, _, pos)| pos.0)
830 834 .collect();
831 835 let hour = hour_of_day(*tick);
832 - for (entity, tile, mut path, mut job, mut idle, mut carrying, rest, hunger) in &mut colonists {
836 + for (entity, tile, mut path, mut job, mut idle, mut carrying, rest, hunger, family, child) in
837 + &mut colonists
838 + {
833 839 let exhausted = rest.0 < EXHAUSTED_REST;
834 840 let starving = hunger.0 < STARVING && food_to_fetch(&reservations, &food_tiles);
835 841 let urgent = exhausted || starving;
@@ -857,6 +863,9 @@ fn choose_jobs(
857 863 items: &snapshot,
858 864 structures: &structures_list,
859 865 blueprints: &blueprints,
866 + homes: &homes,
867 + family: family.copied(),
868 + child,
860 869 };
861 870 let food = wants_food(hunger.0)
862 871 .then(|| find_food(&map, &reservations, &snapshot, tile.0))
@@ -877,6 +886,8 @@ fn choose_jobs(
877 886 Some(Task::Sleep {
878 887 spot: sleep.spot(tile.0),
879 888 })
889 + } else if child {
890 + None
880 891 } else if let Some(load) = carrying.0 {
881 892 let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind);
882 893 if storage.is_none() {
@@ -911,7 +922,7 @@ fn choose_jobs(
911 922 .then(|| find_sowing(&map, &zones, &reservations, tile.0))
912 923 .flatten()
913 924 })
914 - .or_else(|| find_bed_site(&build, colonist_count, tile.0))
925 + .or_else(|| find_bed_site(&build, adults, tile.0))
915 926 .or_else(|| find_blueprint_work(&build, tile.0))
916 927 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
917 928 .or_else(|| {

src/jobs/tests.rs +14 -0

@@ -20,6 +20,7 @@ fn test_app(map: WorldMap) -> App {
20 20 .insert_resource(SimRng::new(1))
21 21 .init_resource::<Time>()
22 22 .init_resource::<Reservations>()
23 + .init_resource::<crate::family::Homes>()
23 24 .init_resource::<ColonyGoals>()
24 25 .init_resource::<ItemAssets>()
25 26 .init_resource::<StructureAssets>()
@@ -1145,3 +1146,16 @@ fn walls_wait_for_colonists_stepping_onto_the_tile() {
1145 1146 assert!(sent);
1146 1147 assert!(app.world().resource::<WorldMap>().is_wall(wall.as_ivec2()));
1147 1148 }
1149 +
1150 +#[test]
1151 +fn children_play_instead_of_working() {
1152 + let mut app = test_app(map_with_trees(&[UVec2::new(4, 2)]));
1153 + set_goals(&mut app, &[(ItemKind::Wood, 50)]);
1154 + let child = spawn_colonist(&mut app, UVec2::new(2, 2));
1155 + app.world_mut()
1156 + .entity_mut(child)
1157 + .insert(crate::family::Child { born: 0 });
1158 + run_ticks(&mut app, 60 * 10);
1159 + assert!(!matches!(task(&app, child), Some(Task::Work { .. })));
1160 + assert_eq!(items_of(&mut app, ItemKind::Wood), 0);
1161 +}

src/main.rs +2 -0

@@ -4,6 +4,7 @@ mod building;
4 4 mod camera;
5 5 mod colonist;
6 6 mod economy;
7 +mod family;
7 8 mod farming;
8 9 mod food;
9 10 mod items;
@@ -64,6 +65,7 @@ fn main() {
64 65 paths::PathsPlugin,
65 66 economy::EconomyPlugin,
66 67 population::PopulationPlugin,
68 + family::FamilyPlugin,
67 69 plants::PlantsPlugin,
68 70 selection::SelectionPlugin,
69 71 zones::ZonesPlugin,

src/needs.rs +81 -4

@@ -5,6 +5,7 @@ use bevy::prelude::*;
5 5 use crate::{
6 6 building::Blueprints,
7 7 colonist::{Colonist, Path},
8 + family::{Family, Homes},
8 9 items::Item,
9 10 jobs::{CurrentJob, Job, Reservations, Target, Task},
10 11 pathfinding::nearest_tile,
@@ -95,6 +96,9 @@ pub struct SleepContext<'a> {
95 96 pub items: &'a [(Item, TilePos)],
96 97 pub structures: &'a [(UVec2, StructureKind, u32)],
97 98 pub blueprints: &'a Blueprints,
99 + pub homes: &'a Homes,
100 + pub family: Option<Family>,
101 + pub child: bool,
98 102 }
99 103
100 104 impl SleepContext<'_> {
@@ -108,11 +112,17 @@ impl SleepContext<'_> {
108 112 && self.reservations.is_free(Target::SleepSpot(tile))
109 113 }
110 114
111 - fn nearest(&self, kind: StructureKind, from: UVec2) -> Option<UVec2> {
115 + fn nearest(
116 + &self,
117 + kind: StructureKind,
118 + from: UVec2,
119 + allowed: impl Fn(UVec2) -> bool,
120 + ) -> Option<UVec2> {
112 121 self.structures
113 122 .iter()
114 123 .filter(|(pos, k, _)| {
115 124 *k == kind
125 + && allowed(*pos)
116 126 && (kind != StructureKind::Bed
117 127 || self.reservations.is_free(Target::SleepSpot(*pos)))
118 128 })
@@ -125,11 +135,26 @@ impl SleepContext<'_> {
125 135 }
126 136
127 137 pub fn spot(&self, from: UVec2) -> UVec2 {
128 - if let Some(bed) = self.nearest(StructureKind::Bed, from) {
138 + if let Some(home) = self.homes.of(self.family) {
139 + let own_bed = (!self.child)
140 + .then(|| self.nearest(StructureKind::Bed, from, |p| home.contains(p)))
141 + .flatten();
142 + let built = home.interior().all(|t| self.map.is_roofed(t.as_ivec2()));
143 + let floor = || {
144 + built
145 + .then(|| home.interior().find(|&t| self.free(t)))
146 + .flatten()
147 + };
148 + if let Some(spot) = own_bed.or_else(floor) {
149 + return spot;
150 + }
151 + }
152 + let communal = |p: UVec2| self.homes.owner(p).is_none();
153 + if let Some(bed) = self.nearest(StructureKind::Bed, from, communal) {
129 154 return bed;
130 155 }
131 156 let anchor = self
132 - .nearest(StructureKind::Campfire, from)
157 + .nearest(StructureKind::Campfire, from, |_| true)
133 158 .unwrap_or_else(|| self.zones.home(from));
134 159 nearest_tile(self.map, anchor, SLEEP_SPOT_SEARCH_TILES, |p| self.free(p)).unwrap_or(from)
135 160 }
@@ -170,7 +195,7 @@ fn show_sleep_markers(
170 195 #[cfg(test)]
171 196 mod tests {
172 197 use super::*;
173 - use crate::world::Terrain;
198 + use crate::{family::Home, world::Terrain};
174 199
175 200 #[test]
176 201 fn colonists_sleep_at_night_and_nap_only_when_tired() {
@@ -294,11 +319,60 @@ mod tests {
294 319 items: &[],
295 320 structures: &structures,
296 321 blueprints: &Blueprints::default(),
322 + homes: &Homes::default(),
323 + family: None,
324 + child: false,
297 325 };
298 326 assert_eq!(ctx.spot(UVec2::new(2, 2)), UVec2::new(16, 15));
299 327 }
300 328
301 329 #[test]
330 + fn families_sleep_at_home_and_leave_other_homes_alone() {
331 + let mut map = WorldMap::filled(UVec2::splat(30), Terrain::Grass);
332 + let zones = Zones::default();
333 + let reservations = Reservations::default();
334 + let mut homes = Homes::default();
335 + let ours = homes.new_family();
336 + let theirs = homes.new_family();
337 + let our_home = Home::at(UVec2::new(20, 20));
338 + let their_home = Home::at(UVec2::new(2, 2));
339 + homes.by_family.insert(ours.0, our_home);
340 + homes.by_family.insert(theirs.0, their_home);
341 + for tile in our_home.interior() {
342 + map.set_roof(tile, true);
343 + }
344 + let mut structures: Vec<(UVec2, StructureKind, u32)> = our_home
345 + .bed_tiles()
346 + .into_iter()
347 + .chain(their_home.bed_tiles())
348 + .map(|t| (t, StructureKind::Bed, u32::MAX))
349 + .collect();
350 + let communal = UVec2::new(12, 12);
351 + structures.push((communal, StructureKind::Bed, u32::MAX));
352 + let blueprints = Blueprints::default();
353 + let ctx = |family, child| SleepContext {
354 + map: &map,
355 + zones: &zones,
356 + reservations: &reservations,
357 + items: &[],
358 + structures: &structures,
359 + blueprints: &blueprints,
360 + homes: &homes,
361 + family,
362 + child,
363 + };
364 + assert!(
365 + our_home
366 + .bed_tiles()
367 + .contains(&ctx(Some(ours), false).spot(UVec2::ONE))
368 + );
369 + assert_eq!(ctx(None, false).spot(UVec2::ONE), communal);
370 + let child_spot = ctx(Some(ours), true).spot(UVec2::ONE);
371 + assert!(our_home.interior().any(|t| t == child_spot));
372 + assert!(!our_home.bed_tiles().contains(&child_spot));
373 + }
374 +
375 + #[test]
302 376 fn sleepers_bed_down_by_the_campfire() {
303 377 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
304 378 let zones = Zones::default();
@@ -311,6 +385,9 @@ mod tests {
311 385 items: &[],
312 386 structures: &fires,
313 387 blueprints: &Blueprints::default(),
388 + homes: &Homes::default(),
389 + family: None,
390 + child: false,
314 391 };
315 392 let spot = ctx.spot(UVec2::new(2, 2));
316 393 assert!(spot.as_vec2().distance(Vec2::splat(15.0)) < 2.0);

src/selection.rs +14 -1

@@ -7,6 +7,7 @@ use crate::{
7 7 camera::CameraController,
8 8 colonist::{Colonist, Path},
9 9 economy::{ColonyGoals, Stock, in_colony},
10 + family::{Child, Partner},
10 11 food::Hunger,
11 12 items::{Carrying, Item, stock},
12 13 jobs::{COOK_WORK, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
@@ -236,6 +237,7 @@ fn spawn_tile_info(mut commands: Commands) {
236 237 ));
237 238 }
238 239
240 +#[allow(clippy::too_many_arguments)]
239 241 fn update_tile_info(
240 242 map: Res<WorldMap>,
241 243 cursor: Res<Cursor>,
@@ -263,6 +265,7 @@ fn update_tile_info(
263 265 Res<SimSpeed>,
264 266 Res<WorldSeed>,
265 267 ),
268 + kin: Query<(Option<&Partner>, Option<&Child>)>,
266 269 mut text: Single<&mut Text, With<TileInfoText>>,
267 270 ) {
268 271 let (time, tick, speed, seed) = clock;
@@ -280,7 +283,17 @@ fn update_tile_info(
280 283 carrying: carrying.0,
281 284 rest: rest.0,
282 285 fed: hunger.0,
283 - })
286 + }) + &kin
287 + .get(entity)
288 + .map(|(partner, child)| {
289 + let partner = partner
290 + .and_then(|p| colonists.get(p.0).ok())
291 + .map(|p| format!(", partner {}", p.0.name));
292 + let child =
293 + child.map(|c| format!(", child (grows up in {}d)", c.days_left(*tick)));
294 + partner.unwrap_or_default() + &child.unwrap_or_default()
295 + })
296 + .unwrap_or_default()
284 297 })
285 298 .unwrap_or_else(|_| "-".into()),
286 299 };

tasks.md +2 -1

@@ -26,12 +26,13 @@
26 26 - [x] Learned stock targets (from consumption, grouped raw food, plus planned construction); food spoilage (slower under roofs); colony growth (newcomers when prosperous) and shrinkage (starvation); event log
27 27 - [x] Performance: food/material searches use precollected tile sets and skip out-of-reach stacks; starving colonists without food keep working
28 28 - [x] Food security: stock counts only items within colony reach; harvesting, foraging and sowing take priority when food is short
29 +- [x] Families: colonists who spend time together become partners, plan and build their own walled, roofed home with two beds, and have children who grow up after 4 days
29 30 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
30 31
31 32 ## Planned
32 33 - [ ] Jobs: building (walls, floors; consumes materials)
33 34 - [ ] Refuelling fires instead of rebuilding; manual build orders for fires
34 -- [ ] Manual wall/room building tool; bed ownership
35 +- [ ] Manual wall/room building tool
35 36 - [ ] Mood, food poisoning from raw food; colonists leaving when unhappy; old age/disease
36 37 - [ ] Animals flee from hunters; tameable animals; butchering as a separate step
37 38 - [ ] Per-colonist work priorities (busy colonies leave low-priority work such as hauling undone)