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>
This commit is contained in:
Steve Biedermann 2026-10-11 17:48:26 +02:00
parent ca371bd414
commit 2cb4d4e626
1 changed files with 501 additions and 20 deletions

View File

@ -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<Self> {
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<u32>,
}
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<Person>,
rabbits: Vec<Animal>,
wolves: Vec<Animal>,
partnerships: Vec<Partnership>,
next_partnership_id: u32,
actor_system: ActorSystem,
entities: HashMap<u64, ActorRef<EntityMessage>>,
next_actor_id: u64,
@ -391,6 +491,14 @@ struct WorldSnapshot {
terrain_rev: u32,
#[serde(default)]
calendar: CalendarSettings,
#[serde(default)]
partnerships: Vec<Partnership>,
#[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<u32> = 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::<String>(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,7 +1515,14 @@ impl World {
}
}
Some("stage") => {
if let (Some(stage), Some(min_days), Some(max_days)) = (t.get(1), num(2), num(3)) {
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 {
@ -1160,6 +1533,7 @@ impl World {
}
}
}
}
Some("speed") => {
if let Some(n) = num(1) {
self.speed = n.clamp(1, 8) as u32;
@ -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::<serde_json::Value>(&restored.state_json()).unwrap()["people"][0]
[8],
"young_adult"
);
}
#[test]
fn snapshot_restores_state_and_rng() {