diff --git a/README.md b/README.md index 94a9c29..ae41ddb 100644 --- a/README.md +++ b/README.md @@ -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. -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. diff --git a/src/world.rs b/src/world.rs index eb8eb59..445ff47 100644 --- a/src/world.rs +++ b/src/world.rs @@ -5,7 +5,8 @@ use picard::actors::{ use rune::termcolor::Buffer; use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm}; 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::sync::{ Arc, @@ -206,6 +207,7 @@ const TREE: u8 = 1; const BERRY: u8 = 2; const STONE: u8 = 3; const BASE_POPULATION_CAPACITY: usize = 12; +const STARTING_FOOD: u32 = 60; pub struct Rng(u64); @@ -545,7 +547,7 @@ impl World { next_actor_id: 1, houses: vec![], home: (W / 2, H / 2), - food: 4, + food: STARTING_FOOD, wood: 0, stone: 0, tools: 0, @@ -861,9 +863,54 @@ impl World { } fn move_toward(&mut self, from: (usize, usize), to: (usize, usize)) -> (usize, usize) { - let dx = (to.0 as i32 - from.0 as i32).signum(); - let dy = (to.1 as i32 - from.1 as i32).signum(); - self.move_dir(from.0, from.1, dx, dy) + if from == to { + return from; + } + + 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)> { @@ -1033,6 +1080,7 @@ impl World { } 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() { if self.people[first].partner_id.is_some() || !self.people[first].stage.can_partner() @@ -1067,7 +1115,7 @@ impl World { first_person_id: first_id, second_person_id: second_id, formed_tick: self.tick, - last_birth_tick: self.tick, + last_birth_tick: self.tick / ticks_per_week * ticks_per_week, actor_id: 0, }); self.next_partnership_id += 1; @@ -1158,13 +1206,7 @@ impl World { let pos = (p.x, p.y); let home = self.home; let at_home = cheb(pos, home) <= 1; - p.hunger += if night && at_home { 0.04 } else { 0.1 }; - if p.hunger >= 100.0 { - p.dead = true; - self.say(format!("Person #{} starved", p.id)); - self.people[i] = p; - return; - } + p.hunger += if night && at_home { 0.01 } else { 0.02 }; if at_home { self.food += p.food; self.wood += p.wood; @@ -1182,6 +1224,18 @@ impl World { p.food -= 1; 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 !at_home { (p.x, p.y) = self.move_toward(pos, home); @@ -1906,7 +1960,7 @@ impl Drop for World { #[cfg(test)] 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] 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::>(), + world.log, + ); + assert!( + world.log.iter().all(|event| !event.contains("starved")), + "people starved before the first birth check: {:?}", + world.log + ); + } + #[test] fn legacy_snapshots_restore_the_village_as_adults() { let world = World::new(42); @@ -2072,6 +2173,19 @@ mod tests { 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] fn snapshot_restores_state_and_rng() { let mut original = World::new(42);