From 2cb4d4e6265976f147ea56ef3d3f26bdf63c3cb9 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Sun, 11 Oct 2026 17:48:26 +0200 Subject: [PATCH] feat(sim): add human life stages and partnerships Add stage progression through death, one-to-one partnership actors, and constrained births. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/world.rs | 521 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 501 insertions(+), 20 deletions(-) diff --git a/src/world.rs b/src/world.rs index 882b171..1f3f8d5 100644 --- a/src/world.rs +++ b/src/world.rs @@ -21,6 +21,8 @@ pub const DEFAULT_TICKS_PER_DAY: u64 = 1800; pub struct StageDuration { pub min_days: u32, pub max_days: u32, + #[serde(default)] + pub fertile: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -44,6 +46,7 @@ impl Default for StageDuration { Self { min_days: 1, max_days: 2, + fertile: false, } } } @@ -58,36 +61,56 @@ impl Default for CalendarSettings { toddler: StageDuration { min_days: 2, max_days: 4, + fertile: false, }, child: StageDuration { min_days: 4, max_days: 8, + fertile: false, }, teen: StageDuration { min_days: 4, max_days: 8, + fertile: false, }, young_adult: StageDuration { min_days: 6, max_days: 12, + fertile: true, }, adult: StageDuration { min_days: 20, max_days: 40, + fertile: true, }, older_adult: StageDuration { min_days: 10, max_days: 20, + fertile: false, }, elderly: StageDuration { min_days: 4, max_days: 10, + fertile: false, }, } } } impl CalendarSettings { + fn duration(&self, stage: &str) -> Option<&StageDuration> { + match stage { + "toddler" => Some(&self.toddler), + "child" => Some(&self.child), + "teen" => Some(&self.teen), + "young_adult" => Some(&self.young_adult), + "adult" => Some(&self.adult), + "older_adult" => Some(&self.older_adult), + "elderly" => Some(&self.elderly), + _ => None, + } + } + fn normalize(&mut self) { self.ticks_per_day = self.ticks_per_day.clamp(1, 1_000_000); self.days_per_week = self.days_per_week.clamp(1, 100); @@ -122,6 +145,55 @@ impl CalendarSettings { } } +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum LifeStage { + #[default] + Toddler, + Child, + Teen, + YoungAdult, + Adult, + OlderAdult, + Elderly, + Dead, +} + +impl LifeStage { + fn next(self) -> Option { + match self { + Self::Toddler => Some(Self::Child), + Self::Child => Some(Self::Teen), + Self::Teen => Some(Self::YoungAdult), + Self::YoungAdult => Some(Self::Adult), + Self::Adult => Some(Self::OlderAdult), + Self::OlderAdult => Some(Self::Elderly), + Self::Elderly => Some(Self::Dead), + Self::Dead => None, + } + } + + fn key(self) -> &'static str { + match self { + Self::Toddler => "toddler", + Self::Child => "child", + Self::Teen => "teen", + Self::YoungAdult => "young_adult", + Self::Adult => "adult", + Self::OlderAdult => "older_adult", + Self::Elderly => "elderly", + Self::Dead => "dead", + } + } + + fn can_partner(self) -> bool { + matches!( + self, + Self::YoungAdult | Self::Adult | Self::OlderAdult | Self::Elderly + ) + } +} + const WATER_DEEP: u8 = 0; const WATER: u8 = 1; const SAND: u8 = 2; @@ -275,6 +347,20 @@ pub struct Person { stone: u32, action: String, dead: bool, + #[serde(default)] + age_ticks: u64, + #[serde(default)] + stage: LifeStage, + #[serde(default)] + stage_elapsed_ticks: u64, + #[serde(default = "default_stage_duration_days")] + stage_duration_days: u32, + #[serde(default)] + partner_id: Option, +} + +fn default_stage_duration_days() -> u32 { + 1 } impl Person { @@ -298,6 +384,18 @@ enum EntityKind { Person(Person), Rabbit(Animal), Wolf(Animal), + Partnership(Partnership), +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Partnership { + id: u32, + first_person_id: u32, + second_person_id: u32, + formed_tick: u64, + last_birth_tick: u64, + #[serde(skip)] + actor_id: u64, } #[derive(Clone, Debug)] @@ -345,6 +443,8 @@ pub struct World { people: Vec, rabbits: Vec, wolves: Vec, + partnerships: Vec, + next_partnership_id: u32, actor_system: ActorSystem, entities: HashMap>, next_actor_id: u64, @@ -391,6 +491,14 @@ struct WorldSnapshot { terrain_rev: u32, #[serde(default)] calendar: CalendarSettings, + #[serde(default)] + partnerships: Vec, + #[serde(default = "default_next_partnership_id")] + next_partnership_id: u32, +} + +fn default_next_partnership_id() -> u32 { + 1 } fn cheb(a: (usize, usize), b: (usize, usize)) -> usize { @@ -429,6 +537,8 @@ impl World { people: vec![], rabbits: vec![], wolves: vec![], + partnerships: vec![], + next_partnership_id: 1, actor_system: ActorSystem::new().expect("failed to start Picard actor system"), entities: HashMap::new(), next_actor_id: 1, @@ -463,8 +573,35 @@ impl World { } let (hx, hy) = w.home; w.res[hy * W + hx] = (0, 0); - for _ in 0..9 { + for i in 0..9 { w.spawn_person(); + let stage = if i < 3 { + LifeStage::YoungAdult + } else { + LifeStage::Adult + }; + let duration_days = w.sample_stage_duration(stage); + let prior_stages = match stage { + LifeStage::YoungAdult => { + [LifeStage::Toddler, LifeStage::Child, LifeStage::Teen].as_slice() + } + LifeStage::Adult => [ + LifeStage::Toddler, + LifeStage::Child, + LifeStage::Teen, + LifeStage::YoungAdult, + ] + .as_slice(), + _ => &[], + }; + let age_days: u64 = prior_stages + .iter() + .map(|&past_stage| w.sample_stage_duration(past_stage) as u64) + .sum(); + let person = w.people.last_mut().expect("just spawned person"); + person.stage = stage; + person.stage_duration_days = duration_days; + person.age_ticks = age_days * w.calendar.ticks_per_day; } for _ in 0..60 { if let Some(p) = w.random_land() { @@ -520,11 +657,13 @@ impl World { self.people.clear(); self.rabbits.clear(); self.wolves.clear(); + self.partnerships.clear(); for state in states { match state.kind { EntityKind::Person(person) => self.people.push(person), EntityKind::Rabbit(animal) => self.rabbits.push(animal), EntityKind::Wolf(animal) => self.wolves.push(animal), + EntityKind::Partnership(partnership) => self.partnerships.push(partnership), } } } @@ -542,6 +681,12 @@ impl World { self.next_actor_id += 1; } } + for partnership in &mut self.partnerships { + if partnership.actor_id == 0 { + partnership.actor_id = self.next_actor_id; + self.next_actor_id += 1; + } + } let states = self .people @@ -558,7 +703,16 @@ impl World { .chain(self.wolves.iter().cloned().map(|animal| EntityState { id: animal.actor_id, kind: EntityKind::Wolf(animal), - })); + })) + .chain( + self.partnerships + .iter() + .cloned() + .map(|partnership| EntityState { + id: partnership.actor_id, + kind: EntityKind::Partnership(partnership), + }), + ); let mut active = HashSet::new(); for state in states { active.insert(state.id); @@ -614,7 +768,11 @@ impl World { } fn spawn_person(&mut self) { - let (x, y) = self.home; + self.spawn_person_at(self.home); + } + + fn spawn_person_at(&mut self, (x, y): (usize, usize)) { + let stage_duration_days = self.sample_stage_duration(LifeStage::Toddler); self.people.push(Person { actor_id: 0, id: self.next_id, @@ -626,10 +784,34 @@ impl World { stone: 0, action: "idle".to_string(), dead: false, + age_ticks: 0, + stage: LifeStage::Toddler, + stage_elapsed_ticks: 0, + stage_duration_days, + partner_id: None, }); self.next_id += 1; } + fn sample_stage_duration(&mut self, stage: LifeStage) -> u32 { + let duration = match stage { + LifeStage::Toddler => self.calendar.toddler, + LifeStage::Child => self.calendar.child, + LifeStage::Teen => self.calendar.teen, + LifeStage::YoungAdult => self.calendar.young_adult, + LifeStage::Adult => self.calendar.adult, + LifeStage::OlderAdult => self.calendar.older_adult, + LifeStage::Elderly => self.calendar.elderly, + LifeStage::Dead => StageDuration { + min_days: 1, + max_days: 1, + fertile: false, + }, + }; + let range = duration.max_days - duration.min_days + 1; + duration.min_days + self.rng.below(range as usize) as u32 + } + fn say(&mut self, s: String) { self.log.push(format!( "[day {}] {}", @@ -714,6 +896,7 @@ impl World { self.load_entities(); self.tick += 1; self.regrow(); + self.advance_people(); if self.tick % 2 == 0 { for i in 0..self.people.len() { self.tick_person(i); @@ -729,10 +912,72 @@ impl World { self.people.retain(|p| !p.dead); self.rabbits.retain(|a| !a.dead); self.wolves.retain(|a| !a.dead); + self.prune_partnerships(); self.population(); self.sync_entities(); } + fn advance_people(&mut self) { + let mut notices = Vec::new(); + for i in 0..self.people.len() { + if self.people[i].dead { + continue; + } + self.people[i].age_ticks = self.people[i].age_ticks.saturating_add(1); + self.people[i].stage_elapsed_ticks = + self.people[i].stage_elapsed_ticks.saturating_add(1); + let duration_ticks = + self.people[i].stage_duration_days as u64 * self.calendar.ticks_per_day; + if self.people[i].stage_elapsed_ticks < duration_ticks { + continue; + } + + let person_id = self.people[i].id; + if let Some(next) = self.people[i].stage.next() { + self.people[i].stage = next; + self.people[i].stage_elapsed_ticks = 0; + if next == LifeStage::Dead { + self.people[i].dead = true; + notices.push(format!("Person #{person_id} died of old age")); + } else { + self.people[i].stage_duration_days = self.sample_stage_duration(next); + notices.push(format!( + "Person #{person_id} became a {}", + next.key().replace('_', " ") + )); + } + } else { + self.people[i].dead = true; + notices.push(format!("Person #{person_id} died of old age")); + } + } + for notice in notices { + self.say(notice); + } + } + + fn prune_partnerships(&mut self) { + let alive: HashSet = self.people.iter().map(|person| person.id).collect(); + self.partnerships.retain(|partnership| { + alive.contains(&partnership.first_person_id) + && alive.contains(&partnership.second_person_id) + }); + let partnered: HashSet<(u32, u32)> = self + .partnerships + .iter() + .map(|partnership| (partnership.first_person_id, partnership.second_person_id)) + .flat_map(|(first, second)| [(first, second), (second, first)]) + .collect(); + for person in &mut self.people { + if person + .partner_id + .is_some_and(|partner| !partnered.contains(&(person.id, partner))) + { + person.partner_id = None; + } + } + } + fn regrow(&mut self) { for _ in 0..20 { let i = self.rng.below(W * H); @@ -750,11 +995,15 @@ impl World { } fn population(&mut self) { - if self.tick % 400 == 0 && self.food >= 10 && self.people.len() < 8 + self.houses.len() * 3 - { - self.food -= 6; - self.spawn_person(); - self.say("A child was born".into()); + let ticks_per_day = self.calendar.ticks_per_day; + if self.tick.is_multiple_of(ticks_per_day) { + self.find_partners(); + } + let ticks_per_month = ticks_per_day + * self.calendar.days_per_week as u64 + * self.calendar.weeks_per_month as u64; + if self.tick.is_multiple_of(ticks_per_month) { + self.have_children(ticks_per_month); } if self.tick % 50 == 0 { if self.rabbits.len() < 15 { @@ -784,6 +1033,110 @@ impl World { } } + fn find_partners(&mut self) { + for first in 0..self.people.len() { + if self.people[first].partner_id.is_some() + || !self.people[first].stage.can_partner() + || self.people[first].dead + { + continue; + } + let first_position = (self.people[first].x, self.people[first].y); + let second = (0..self.people.len()) + .filter(|&index| { + index != first + && self.people[index].partner_id.is_none() + && self.people[index].stage.can_partner() + && !self.people[index].dead + }) + .filter_map(|index| { + let distance = + cheb(first_position, (self.people[index].x, self.people[index].y)); + (distance <= 12).then_some((index, distance)) + }) + .min_by_key(|(_, distance)| *distance) + .map(|(index, _)| index); + let Some(second) = second else { + continue; + }; + let first_id = self.people[first].id; + let second_id = self.people[second].id; + self.people[first].partner_id = Some(second_id); + self.people[second].partner_id = Some(first_id); + self.partnerships.push(Partnership { + id: self.next_partnership_id, + first_person_id: first_id, + second_person_id: second_id, + formed_tick: self.tick, + last_birth_tick: self.tick, + actor_id: 0, + }); + self.next_partnership_id += 1; + self.say(format!( + "People #{first_id} and #{second_id} became partners" + )); + } + } + + fn have_children(&mut self, ticks_per_month: u64) { + if self.food < 10 || self.people.len() >= 8 + self.houses.len() * 3 { + return; + } + let partnerships: Vec<_> = self + .partnerships + .iter() + .map(|partnership| { + ( + partnership.id, + partnership.first_person_id, + partnership.second_person_id, + partnership.last_birth_tick, + ) + }) + .collect(); + for (partnership_id, first_id, second_id, last_birth_tick) in partnerships { + if self.food < 10 || self.people.len() >= 8 + self.houses.len() * 3 { + break; + } + if self.tick.saturating_sub(last_birth_tick) < ticks_per_month { + continue; + } + let Some(first_index) = self.people.iter().position(|p| p.id == first_id) else { + continue; + }; + let Some(second_index) = self.people.iter().position(|p| p.id == second_id) else { + continue; + }; + let first_stage = self.people[first_index].stage; + let second_stage = self.people[second_index].stage; + let can_have_children = self + .calendar + .duration(first_stage.key()) + .is_some_and(|duration| duration.fertile) + && self + .calendar + .duration(second_stage.key()) + .is_some_and(|duration| duration.fertile); + if !can_have_children || !self.rng.chance(0.2) { + continue; + } + + let location = (self.people[first_index].x, self.people[first_index].y); + self.food -= 6; + self.spawn_person_at(location); + if let Some(partnership) = self + .partnerships + .iter_mut() + .find(|partnership| partnership.id == partnership_id) + { + partnership.last_birth_tick = self.tick; + } + self.say(format!( + "People #{first_id} and #{second_id} welcomed a toddler" + )); + } + } + fn decide(&mut self, func: &str, c: Ctx) -> String { match self.vm.call([func], (c,)) { Ok(v) => rune::from_value::(v).unwrap_or_else(|_| "wander".into()), @@ -796,6 +1149,9 @@ impl World { fn tick_person(&mut self, i: usize) { let mut p = self.people[i].clone(); + if p.dead { + return; + } let night = self.daylight() < 0.2; let pos = (p.x, p.y); let home = self.home; @@ -824,6 +1180,11 @@ impl World { p.food -= 1; p.hunger = (p.hunger - 25.0).max(0.0); } + if !p.stage.can_partner() { + p.action = "growing up".to_string(); + self.people[i] = p; + return; + } let berry = self.nearest_res(pos, BERRY, 25); let tree = self.nearest_res(pos, TREE, 25); @@ -1066,6 +1427,8 @@ impl World { speed: self.speed, terrain_rev: self.terrain_rev, calendar: self.calendar.clone(), + partnerships: self.partnerships.clone(), + next_partnership_id: self.next_partnership_id, }) } @@ -1092,6 +1455,8 @@ impl World { people: saved.people, rabbits: saved.rabbits, wolves: saved.wolves, + partnerships: saved.partnerships, + next_partnership_id: saved.next_partnership_id, actor_system: ActorSystem::new() .map_err(|e| format!("failed to start Picard actor system: {e}"))?, entities: HashMap::new(), @@ -1114,6 +1479,7 @@ impl World { calendar: saved.calendar, }; world.calendar.normalize(); + world.prune_partnerships(); world.sync_entities(); Ok(world) } @@ -1149,13 +1515,21 @@ impl World { } } Some("stage") => { - if let (Some(stage), Some(min_days), Some(max_days)) = (t.get(1), num(2), num(3)) { - let min_days = min_days.clamp(1, 1_000_000) as u32; - let max_days = max_days.clamp(1, 1_000_000) as u32; - if min_days <= max_days { - if let Some(range) = self.calendar.duration_mut(stage) { - range.min_days = min_days; - range.max_days = max_days; + if let (Some(stage), Some(property)) = (t.get(1), t.get(2)) { + if *property == "fertile" { + if let Some(value) = num(3) { + if let Some(range) = self.calendar.duration_mut(stage) { + range.fertile = value != 0; + } + } + } else if let (Some(min_days), Some(max_days)) = (num(2), num(3)) { + let min_days = min_days.clamp(1, 1_000_000) as u32; + let max_days = max_days.clamp(1, 1_000_000) as u32; + if min_days <= max_days { + if let Some(range) = self.calendar.duration_mut(stage) { + range.min_days = min_days; + range.max_days = max_days; + } } } } @@ -1304,6 +1678,7 @@ impl World { } _ => {} } + self.prune_partnerships(); self.sync_entities(); } @@ -1400,8 +1775,8 @@ impl World { } let _ = write!( s, - "\"{name}\":{{\"min_days\":{},\"max_days\":{}}}", - range.min_days, range.max_days + "\"{name}\":{{\"min_days\":{},\"max_days\":{},\"fertile\":{}}}", + range.min_days, range.max_days, range.fertile ); } s.push_str("},"); @@ -1418,8 +1793,19 @@ impl World { .iter() .map(|p| { format!( - "[{},{},{},\"{}\",{:.0},{},{},{}]", - p.id, p.x, p.y, p.action, p.hunger, p.food, p.wood, p.stone + "[{},{},{},\"{}\",{:.0},{},{},{},\"{}\",{},{}]", + p.id, + p.x, + p.y, + p.action, + p.hunger, + p.food, + p.wood, + p.stone, + p.stage.key(), + p.age_ticks / self.calendar.ticks_per_day, + p.partner_id + .map_or_else(|| "null".to_string(), |partner| partner.to_string()) ) }) .collect(); @@ -1465,7 +1851,102 @@ impl Drop for World { #[cfg(test)] mod tests { - use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, World}; + use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, LifeStage, World}; + + #[test] + fn new_people_progress_through_every_stage_and_die() { + let mut world = World::new(42); + world.calendar.ticks_per_day = 1; + for stage in [ + "toddler", + "child", + "teen", + "young_adult", + "adult", + "older_adult", + "elderly", + ] { + let duration = world.calendar.duration_mut(stage).unwrap(); + duration.min_days = 1; + duration.max_days = 1; + } + world.spawn_person(); + let index = world.people.len() - 1; + assert_eq!(world.people[index].stage, LifeStage::Toddler); + + for expected in [ + LifeStage::Child, + LifeStage::Teen, + LifeStage::YoungAdult, + LifeStage::Adult, + LifeStage::OlderAdult, + LifeStage::Elderly, + LifeStage::Dead, + ] { + world.advance_people(); + assert_eq!(world.people[index].stage, expected); + } + assert!(world.people[index].dead); + } + + #[test] + fn eligible_people_form_partnership_actor_and_have_toddlers() { + let mut world = World::new(42); + world.calendar.ticks_per_day = 1; + world.calendar.days_per_week = 1; + world.calendar.weeks_per_month = 1; + world.food = 1000; + world.houses.push((0, 0)); + world.find_partners(); + assert_eq!(world.partnerships.len(), 4); + assert_eq!( + world + .people + .iter() + .filter(|person| person.partner_id.is_some()) + .count(), + 8 + ); + + world.sync_entities(); + assert_eq!( + world.entities.len(), + world.people.len() + world.rabbits.len() + world.wolves.len() + 4 + ); + world.load_entities(); + assert_eq!(world.partnerships.len(), 4); + + let initial_population = world.people.len(); + for _ in 0..100 { + world.tick += 1; + world.have_children(1); + if world.people.len() > initial_population { + break; + } + } + let toddler = world + .people + .iter() + .find(|person| person.stage == LifeStage::Toddler) + .expect("fertile partner actors should eventually have a child"); + assert_eq!(toddler.age_ticks, 0); + } + + #[test] + fn partnership_and_life_stage_are_in_persisted_actor_state() { + let mut world = World::new(42); + world.find_partners(); + world.sync_entities(); + let restored = World::from_snapshot(&world.snapshot_json().unwrap(), 42).unwrap(); + + assert_eq!(restored.partnerships.len(), 4); + assert_eq!(restored.state_json(), world.state_json()); + assert_eq!( + serde_json::from_str::(&restored.state_json()).unwrap()["people"][0] + [8], + "young_adult" + ); + } #[test] fn snapshot_restores_state_and_rng() {