fix(sim): keep legacy villagers and newborns alive

Restore pre-life-cycle snapshots with working-age villagers and keep dependents near the shared food stock. Add regression coverage for legacy saves, child return-home behavior, and newborn placement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-11 18:04:36 +02:00
parent a0f18df9ef
commit 1e867b173c
1 changed files with 106 additions and 4 deletions

View File

@ -1121,9 +1121,8 @@ impl World {
continue; continue;
} }
let location = (self.people[first_index].x, self.people[first_index].y);
self.food -= 6; self.food -= 6;
self.spawn_person_at(location); self.spawn_person_at(self.home);
if let Some(partnership) = self if let Some(partnership) = self
.partnerships .partnerships
.iter_mut() .iter_mut()
@ -1181,6 +1180,9 @@ impl World {
p.hunger = (p.hunger - 25.0).max(0.0); p.hunger = (p.hunger - 25.0).max(0.0);
} }
if !p.stage.can_partner() { if !p.stage.can_partner() {
if !at_home {
(p.x, p.y) = self.move_toward(pos, home);
}
p.action = "growing up".to_string(); p.action = "growing up".to_string();
self.people[i] = p; self.people[i] = p;
return; return;
@ -1437,7 +1439,21 @@ impl World {
} }
pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result<Self, String> { pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result<Self, String> {
let saved: WorldSnapshot = serde_json::from_str(snapshot).map_err(|e| e.to_string())?; let snapshot_value: serde_json::Value =
serde_json::from_str(snapshot).map_err(|e| e.to_string())?;
let legacy_people = snapshot_value
.get("people")
.and_then(serde_json::Value::as_array)
.is_some_and(|people| {
!people.is_empty()
&& people.iter().all(|person| {
person
.as_object()
.is_some_and(|person| !person.contains_key("stage"))
})
});
let saved: WorldSnapshot =
serde_json::from_value(snapshot_value).map_err(|e| e.to_string())?;
if saved.version != 1 { if saved.version != 1 {
return Err(format!( return Err(format!(
"unsupported world snapshot version {}", "unsupported world snapshot version {}",
@ -1479,11 +1495,47 @@ impl World {
calendar: saved.calendar, calendar: saved.calendar,
}; };
world.calendar.normalize(); world.calendar.normalize();
if legacy_people {
world.initialize_legacy_people();
}
world.prune_partnerships(); world.prune_partnerships();
world.sync_entities(); world.sync_entities();
Ok(world) Ok(world)
} }
fn initialize_legacy_people(&mut self) {
for index in 0..self.people.len() {
let stage = if index < 3 {
LifeStage::YoungAdult
} else {
LifeStage::Adult
};
let duration_days = self.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| self.sample_stage_duration(past_stage) as u64)
.sum();
let person = &mut self.people[index];
person.stage = stage;
person.stage_duration_days = duration_days;
person.stage_elapsed_ticks = 0;
person.age_ticks = age_days * self.calendar.ticks_per_day;
}
}
fn rect(t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> { fn rect(t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> {
let n: Vec<i64> = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect(); let n: Vec<i64> = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect();
if n.len() != 4 { if n.len() != 4 {
@ -1851,7 +1903,7 @@ impl Drop for World {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, LifeStage, World}; use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, LifeStage, World, cheb};
#[test] #[test]
fn new_people_progress_through_every_stage_and_die() { fn new_people_progress_through_every_stage_and_die() {
@ -1907,6 +1959,8 @@ mod tests {
.count(), .count(),
8 8
); );
world.people[0].x = 0;
world.people[0].y = 0;
world.sync_entities(); world.sync_entities();
assert_eq!( assert_eq!(
@ -1930,6 +1984,39 @@ mod tests {
.find(|person| person.stage == LifeStage::Toddler) .find(|person| person.stage == LifeStage::Toddler)
.expect("fertile partner actors should eventually have a child"); .expect("fertile partner actors should eventually have a child");
assert_eq!(toddler.age_ticks, 0); assert_eq!(toddler.age_ticks, 0);
assert_eq!((toddler.x, toddler.y), world.home);
}
#[test]
fn legacy_snapshots_restore_the_village_as_adults() {
let world = World::new(42);
let mut snapshot: serde_json::Value =
serde_json::from_str(&world.snapshot_json().unwrap()).unwrap();
for person in snapshot["people"].as_array_mut().unwrap() {
let person = person.as_object_mut().unwrap();
for field in [
"age_ticks",
"stage",
"stage_elapsed_ticks",
"stage_duration_days",
"partner_id",
] {
person.remove(field);
}
}
snapshot.as_object_mut().unwrap().remove("partnerships");
let restored = World::from_snapshot(&snapshot.to_string(), 42).unwrap();
assert_eq!(restored.people.len(), 9);
assert_eq!(restored.people[0].stage, LifeStage::YoungAdult);
assert!(
restored
.people
.iter()
.skip(3)
.all(|person| person.stage == LifeStage::Adult)
);
} }
#[test] #[test]
@ -1948,6 +2035,21 @@ mod tests {
); );
} }
#[test]
fn children_return_home_instead_of_staying_away_from_food() {
let mut world = World::new(42);
world.food = 100;
let home = world.home;
let position = (home.0.saturating_add(5), home.1.saturating_add(5));
world.spawn_person_at(position);
world.tick_person(world.people.len() - 1);
let child = world.people.last().unwrap();
assert!(cheb((child.x, child.y), home) < cheb(position, home));
assert_eq!(child.action, "growing up");
}
#[test] #[test]
fn snapshot_restores_state_and_rng() { fn snapshot_restores_state_and_rng() {
let mut original = World::new(42); let mut original = World::new(42);