fix(sim): prevent starvation before population can recover

Use terrain-aware pathfinding for travel toward home and resources so settlers do not get stranded against blocked terrain. Add a startup food reserve, slow hunger accumulation, let villagers return for food sooner, ensure available food is consumed before starvation, and make the first birth check available at the first weekly boundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-11 18:39:50 +02:00
parent 2220a6eb3c
commit a4eb181154
2 changed files with 129 additions and 15 deletions

View File

@ -6,7 +6,7 @@ Living simulation entities (people, rabbits, and wolves) run as Picard actors. T
The **Calendar and life-stage durations** panel configures ticks per day and the number of days, weeks, and months in larger calendar units. Each human life stage has an inclusive minimum/maximum duration in in-game days; the world samples within that range when a person enters the stage. These settings are stored with the world snapshot. The **Calendar and life-stage durations** panel configures ticks per day and the number of days, weeks, and months in larger calendar units. Each human life stage has an inclusive minimum/maximum duration in in-game days; the world samples within that range when a person enters the stage. These settings are stored with the world snapshot.
New people, including babies, start as toddlers and age through child, teen, young adult, adult, older adult, elderly, and death. The starting village is seeded with young and mature adults so the simulation can work and form the first partnerships immediately. Unpartnered people from young adulthood onward can form a single partnership with a nearby eligible person. Each partnership can have one toddler per calendar week when both partners are in stages marked fertile, the stockpile has enough food, and housing capacity permits it. The starting village has capacity for 12 people, with each additional house increasing capacity by three. Young adult and adult stages are fertile by default; fertility can be changed per stage in the calendar panel. Partnerships are Picard actors as well as people and animals. New people, including babies, start as toddlers and age through child, teen, young adult, adult, older adult, elderly, and death. The starting village is seeded with young and mature adults and a reserve of 60 food so the simulation can work and form the first partnerships immediately. People pathfind around blocked terrain when travelling home for food. Unpartnered people from young adulthood onward can form a single partnership with a nearby eligible person. Each partnership can have one toddler per calendar week when both partners are in stages marked fertile, the stockpile has enough food, and housing capacity permits it. The starting village has capacity for 12 people, with each additional house increasing capacity by three. Young adult and adult stages are fertile by default; fertility can be changed per stage in the calendar panel. Partnerships are Picard actors as well as people and animals.
The UI is built automatically as part of `cargo run` and embedded in the server binary. There is no separate manual UI build step. The UI is built automatically as part of `cargo run` and embedded in the server binary. There is no separate manual UI build step.

View File

@ -5,7 +5,8 @@ use picard::actors::{
use rune::termcolor::Buffer; use rune::termcolor::Buffer;
use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm}; use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::fmt::Write; use std::fmt::Write;
use std::sync::{ use std::sync::{
Arc, Arc,
@ -206,6 +207,7 @@ const TREE: u8 = 1;
const BERRY: u8 = 2; const BERRY: u8 = 2;
const STONE: u8 = 3; const STONE: u8 = 3;
const BASE_POPULATION_CAPACITY: usize = 12; const BASE_POPULATION_CAPACITY: usize = 12;
const STARTING_FOOD: u32 = 60;
pub struct Rng(u64); pub struct Rng(u64);
@ -545,7 +547,7 @@ impl World {
next_actor_id: 1, next_actor_id: 1,
houses: vec![], houses: vec![],
home: (W / 2, H / 2), home: (W / 2, H / 2),
food: 4, food: STARTING_FOOD,
wood: 0, wood: 0,
stone: 0, stone: 0,
tools: 0, tools: 0,
@ -861,9 +863,54 @@ impl World {
} }
fn move_toward(&mut self, from: (usize, usize), to: (usize, usize)) -> (usize, usize) { fn move_toward(&mut self, from: (usize, usize), to: (usize, usize)) -> (usize, usize) {
let dx = (to.0 as i32 - from.0 as i32).signum(); if from == to {
let dy = (to.1 as i32 - from.1 as i32).signum(); return from;
self.move_dir(from.0, from.1, dx, dy) }
let start = from.1 * W + from.0;
let target = to.1 * W + to.0;
let mut frontier = BinaryHeap::from([Reverse((cheb(from, to), start))]);
let mut costs = vec![usize::MAX; W * H];
let mut previous = vec![usize::MAX; W * H];
costs[start] = 0;
previous[start] = start;
while let Some(Reverse((_, current))) = frontier.pop() {
if current == target {
let mut next = target;
while previous[next] != start {
next = previous[next];
}
return (next % W, next / W);
}
let position = (current % W, current / W);
for dy in -1..=1 {
for dx in -1..=1 {
if (dx, dy) == (0, 0) {
continue;
}
let x = position.0 as i32 + dx;
let y = position.1 as i32 + dy;
if !self.walkable(x, y) {
continue;
}
let neighbor = y as usize * W + x as usize;
let cost = costs[current] + 1;
if cost >= costs[neighbor] {
continue;
}
costs[neighbor] = cost;
previous[neighbor] = current;
frontier.push(Reverse((
cost + cheb((x as usize, y as usize), to),
neighbor,
)));
}
}
}
self.wander(from.0, from.1)
} }
fn nearest_res(&self, from: (usize, usize), kind: u8, r: usize) -> Option<(usize, usize)> { fn nearest_res(&self, from: (usize, usize), kind: u8, r: usize) -> Option<(usize, usize)> {
@ -1033,6 +1080,7 @@ impl World {
} }
fn find_partners(&mut self) { fn find_partners(&mut self) {
let ticks_per_week = self.calendar.ticks_per_day * self.calendar.days_per_week as u64;
for first in 0..self.people.len() { for first in 0..self.people.len() {
if self.people[first].partner_id.is_some() if self.people[first].partner_id.is_some()
|| !self.people[first].stage.can_partner() || !self.people[first].stage.can_partner()
@ -1067,7 +1115,7 @@ impl World {
first_person_id: first_id, first_person_id: first_id,
second_person_id: second_id, second_person_id: second_id,
formed_tick: self.tick, formed_tick: self.tick,
last_birth_tick: self.tick, last_birth_tick: self.tick / ticks_per_week * ticks_per_week,
actor_id: 0, actor_id: 0,
}); });
self.next_partnership_id += 1; self.next_partnership_id += 1;
@ -1158,13 +1206,7 @@ impl World {
let pos = (p.x, p.y); let pos = (p.x, p.y);
let home = self.home; let home = self.home;
let at_home = cheb(pos, home) <= 1; let at_home = cheb(pos, home) <= 1;
p.hunger += if night && at_home { 0.04 } else { 0.1 }; p.hunger += if night && at_home { 0.01 } else { 0.02 };
if p.hunger >= 100.0 {
p.dead = true;
self.say(format!("Person #{} starved", p.id));
self.people[i] = p;
return;
}
if at_home { if at_home {
self.food += p.food; self.food += p.food;
self.wood += p.wood; self.wood += p.wood;
@ -1182,6 +1224,18 @@ impl World {
p.food -= 1; p.food -= 1;
p.hunger = (p.hunger - 25.0).max(0.0); p.hunger = (p.hunger - 25.0).max(0.0);
} }
if p.hunger >= 100.0 {
p.dead = true;
self.say(format!("Person #{} starved", p.id));
self.people[i] = p;
return;
}
if p.hunger > 40.0 && self.food > 0 && !at_home {
(p.x, p.y) = self.move_toward(pos, home);
p.action = "returning for food".to_string();
self.people[i] = p;
return;
}
if !p.stage.can_partner() { if !p.stage.can_partner() {
if !at_home { if !at_home {
(p.x, p.y) = self.move_toward(pos, home); (p.x, p.y) = self.move_toward(pos, home);
@ -1906,7 +1960,7 @@ impl Drop for World {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, LifeStage, World, cheb}; use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, H, LifeStage, W, World, cheb};
#[test] #[test]
fn new_people_progress_through_every_stage_and_die() { fn new_people_progress_through_every_stage_and_die() {
@ -2009,6 +2063,53 @@ mod tests {
); );
} }
#[test]
fn people_can_pathfind_back_home_around_blocked_terrain() {
let mut world = World::new(42);
let start = (59, 48);
let mut position = start;
for _ in 0..W.max(H) * 2 {
if position == world.home {
break;
}
position = world.move_toward(position, world.home);
}
assert_eq!(position, world.home);
assert!(world.walkable(start.0 as i32, start.1 as i32));
}
#[test]
fn village_survives_until_its_first_weekly_birth_check() {
let mut world = World::new(42);
let first_week_tick = world.calendar.ticks_per_day * world.calendar.days_per_week as u64;
let mut daily = Vec::new();
while world.tick < first_week_tick {
world.step();
if world.tick.is_multiple_of(world.calendar.ticks_per_day) {
daily.push((world.tick, world.people.len(), world.food));
}
}
assert!(
world.people.len() >= 10,
"village failed to replace its initial population: daily={daily:?}, people={:?}, events={:?}",
world
.people
.iter()
.map(|person| (person.id, person.hunger, person.action.as_str()))
.collect::<Vec<_>>(),
world.log,
);
assert!(
world.log.iter().all(|event| !event.contains("starved")),
"people starved before the first birth check: {:?}",
world.log
);
}
#[test] #[test]
fn legacy_snapshots_restore_the_village_as_adults() { fn legacy_snapshots_restore_the_village_as_adults() {
let world = World::new(42); let world = World::new(42);
@ -2072,6 +2173,19 @@ mod tests {
assert_eq!(child.action, "growing up"); assert_eq!(child.action, "growing up");
} }
#[test]
fn a_person_eats_available_food_before_starving() {
let mut world = World::new(42);
world.food = 1;
world.people[0].hunger = 99.0;
world.tick_person(0);
assert!(!world.people[0].dead);
assert!(world.people[0].hunger < 100.0);
assert_eq!(world.food, 0);
}
#[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);