mod actors; #[allow( dead_code, reason = "the cluster gateway and shard nodes use the remote side" )] mod router; use actors::{ Activity, AnimalId, AnimalMsg, AnimalTurn, Area, BirthCost, Goods, Mailbox, Needs, PartnershipMsg, PersonEdit, PersonMsg, ReplyBody, RequestId, }; use picard::actors::{ActorRef, ActorSystem, Sender, Tell}; use router::{Arrival, LocalActor}; pub use router::{EntityKey, Kind, Router, Shard}; use rune::runtime::RuntimeContext; use rune::termcolor::Buffer; use rune::{Any, Context, Diagnostics, Module, Source, Sources, Unit, Vm}; use serde::{Deserialize, Serialize}; use std::cmp::Reverse; use std::collections::{BTreeMap, BinaryHeap, HashSet}; use std::fmt::Write; use std::ops::Range; use std::sync::{Arc, OnceLock}; pub const W: usize = 96; pub const H: usize = 64; pub const DEFAULT_SCRIPT: &str = include_str!("../config.rn"); #[derive(Clone, Copy, Debug, Deserialize, Serialize, rune::FromValue)] pub struct StageDuration { pub min_days: u32, pub max_days: u32, #[serde(default)] pub fertile: bool, } #[derive(Clone, Debug, Deserialize, Serialize, rune::FromValue)] #[serde(default)] pub struct CalendarSettings { pub ticks_per_day: u64, pub days_per_week: u32, pub weeks_per_month: u32, pub months_per_year: u32, pub toddler: StageDuration, pub child: StageDuration, pub teen: StageDuration, pub young_adult: StageDuration, pub adult: StageDuration, pub older_adult: StageDuration, pub elderly: StageDuration, } impl Default for StageDuration { fn default() -> Self { Self { min_days: 1, max_days: 2, fertile: false, } } } impl Default for CalendarSettings { fn default() -> Self { default_config().calendar } } #[derive(Clone, Debug, rune::FromValue)] pub struct SimulationConfig { width: usize, height: usize, calendar: CalendarSettings, initial_population: usize, initial_young_adults: usize, initial_rabbits: usize, initial_wolves: usize, initial_food: u32, initial_person_hunger: f32, initial_animal_energy: f32, animal_energy_death_threshold: f32, starting_tick_fraction: u64, population_capacity: usize, house_capacity: usize, max_houses: usize, daylight_night_threshold: f32, daylight_base: f32, daylight_amplitude: f32, home_radius: usize, attack_range: usize, entity_update_interval: u64, hunger_night_at_home: f32, hunger_other: f32, hunger_eat_home_threshold: f32, hunger_eat_carried_threshold: f32, hunger_return_threshold: f32, hunger_starvation_threshold: f32, hunger_meal_reduction: f32, food_per_meal: u32, resource_search_radius: usize, person_hunt_success_chance: f32, person_hunt_food_yield: u32, person_gather_yield: u16, person_tooled_gather_yield: u16, partnership_distance: usize, birth_food_threshold: u32, birth_food_cost: u32, tool_wood_cost: u32, tool_stone_cost: u32, house_wood_cost: u32, house_stone_cost: u32, house_placement_radius: usize, resource_regrow_attempts: usize, initial_tree_chance: f32, initial_tree_amount: u16, initial_berry_chance: f32, initial_berry_amount: u16, initial_stone_chance: f32, initial_stone_amount: u16, tree_regrow_chance: f32, tree_regrow_amount: u16, berry_regrow_chance: f32, berry_regrow_amount: u16, stone_regrow_chance: f32, stone_regrow_amount: u16, animal_population_interval: u64, rabbit_spawn_cap: usize, wolf_spawn_cap: usize, wolf_spawn_rabbit_threshold: usize, wolf_min_home_distance: usize, rabbit_energy_drain: f32, rabbit_energy_regen: f32, rabbit_energy_max: f32, rabbit_wolf_detection_range: usize, rabbit_breed_energy: f32, rabbit_max_population: usize, rabbit_breed_chance: f32, rabbit_breed_energy_cost: f32, rabbit_newborn_energy: f32, wolf_energy_drain: f32, wolf_prey_range: usize, wolf_hunt_success_chance: f32, wolf_hunt_energy_gain: f32, wolf_energy_max: f32, wolf_breed_energy: f32, wolf_max_population: usize, wolf_breed_chance: f32, wolf_breed_energy_cost: f32, wolf_newborn_energy: f32, terrain_noise_octaves: usize, terrain_noise_scale: f32, terrain_edge_height: f32, terrain_edge_falloff: f32, terrain_edge_distance_cap: f32, terrain_moisture_offset: f32, terrain_moisture_seed_offset: u64, terrain_deep_water_threshold: f32, terrain_water_threshold: f32, terrain_sand_threshold: f32, terrain_forest_grass_threshold: f32, terrain_hills_threshold: f32, terrain_forest_moisture_threshold: f32, random_land_attempts: usize, log_entry_limit: usize, wander_attempts: usize, wander_radius: usize, manual_spawn_limit: usize, max_simulation_speed: u32, max_stock_edit: u32, max_person_inventory_edit: u32, max_hunger_edit: i64, max_animal_energy_edit: f32, max_resource_edit: u16, } impl SimulationConfig { fn validate(&self) -> Result<(), String> { self.validate_limits()?; self.validate_calendar()?; self.validate_chances()?; self.validate_world()?; self.validate_amounts() } fn validate_limits(&self) -> Result<(), String> { if !(8..=256).contains(&self.width) || !(8..=256).contains(&self.height) { return Err("world width and height must be between 8 and 256".into()); } if self.initial_young_adults > self.initial_population { return Err("initial_young_adults cannot exceed initial_population".into()); } if self.food_per_meal == 0 { return Err("food_per_meal must be at least 1".into()); } if self.population_capacity > 1_000_000 || self.house_capacity > 1_000_000 { return Err("population capacity values exceed supported limits".into()); } if self.initial_population > 10_000 || self.initial_rabbits > 100_000 || self.initial_wolves > 100_000 { return Err("initial population counts exceed supported limits".into()); } if self.max_simulation_speed == 0 || self.max_simulation_speed > 32 || self.max_stock_edit == 0 || self.max_stock_edit > 1_000_000_000 || self.max_person_inventory_edit == 0 || self.max_person_inventory_edit > 1_000_000_000 || self.max_hunger_edit <= 0 || self.max_hunger_edit > 1_000_000_000 || self.max_animal_energy_edit <= 0.0 || self.max_animal_energy_edit > 1_000_000.0 || self.max_resource_edit == 0 || self.max_resource_edit > 1_000 { return Err("simulation edit limits must be positive".into()); } if self.starting_tick_fraction == 0 { return Err("starting_tick_fraction must be at least 1".into()); } Ok(()) } fn validate_calendar(&self) -> Result<(), String> { let calendar = &self.calendar; if calendar.ticks_per_day == 0 || calendar.ticks_per_day > 1_000_000 || calendar.days_per_week == 0 || calendar.days_per_week > 100 || calendar.weeks_per_month == 0 || calendar.weeks_per_month > 100 || calendar.months_per_year == 0 || calendar.months_per_year > 100 { return Err("calendar values are outside their supported ranges".into()); } for stage in [ &calendar.toddler, &calendar.child, &calendar.teen, &calendar.young_adult, &calendar.adult, &calendar.older_adult, &calendar.elderly, ] { if stage.min_days == 0 || stage.min_days > stage.max_days || stage.max_days > 1_000_000 { return Err("life-stage durations must be in the range 1..=1000000".into()); } } Ok(()) } fn validate_chances(&self) -> Result<(), String> { for (name, chance) in [ ( "person_hunt_success_chance", self.person_hunt_success_chance, ), ("initial_tree_chance", self.initial_tree_chance), ("initial_berry_chance", self.initial_berry_chance), ("initial_stone_chance", self.initial_stone_chance), ("tree_regrow_chance", self.tree_regrow_chance), ("berry_regrow_chance", self.berry_regrow_chance), ("stone_regrow_chance", self.stone_regrow_chance), ("rabbit_breed_chance", self.rabbit_breed_chance), ("wolf_breed_chance", self.wolf_breed_chance), ("wolf_hunt_success_chance", self.wolf_hunt_success_chance), ] { if !(0.0..=1.0).contains(&chance) { return Err(format!("{name} must be between 0 and 1")); } } Ok(()) } fn validate_world(&self) -> Result<(), String> { if self.animal_population_interval == 0 || self.entity_update_interval < 2 { return Err( "animal_population_interval must be positive and entity_update_interval at least 2" .into(), ); } if self.terrain_noise_octaves == 0 || self.terrain_noise_octaves > 16 || !self.terrain_noise_scale.is_finite() || self.terrain_noise_scale <= 0.0 { return Err("terrain_noise_octaves and terrain_noise_scale must be positive".into()); } if self.house_placement_radius > self.width.max(self.height) || self.resource_regrow_attempts > self.width * self.height || self.random_land_attempts == 0 || self.wander_attempts == 0 || self.wander_radius == 0 || self.wander_radius > 32 || self.log_entry_limit == 0 { return Err("world search and regrowth values exceed supported limits".into()); } if self.terrain_deep_water_threshold >= self.terrain_water_threshold || self.terrain_water_threshold >= self.terrain_sand_threshold || self.terrain_sand_threshold >= self.terrain_forest_grass_threshold || self.terrain_forest_grass_threshold >= self.terrain_hills_threshold { return Err("terrain thresholds must be in ascending order".into()); } for (name, value) in [ ("terrain_noise_scale", self.terrain_noise_scale), ("terrain_edge_height", self.terrain_edge_height), ("terrain_edge_falloff", self.terrain_edge_falloff), ("terrain_edge_distance_cap", self.terrain_edge_distance_cap), ("terrain_moisture_offset", self.terrain_moisture_offset), ( "terrain_deep_water_threshold", self.terrain_deep_water_threshold, ), ("terrain_water_threshold", self.terrain_water_threshold), ("terrain_sand_threshold", self.terrain_sand_threshold), ( "terrain_forest_grass_threshold", self.terrain_forest_grass_threshold, ), ("terrain_hills_threshold", self.terrain_hills_threshold), ( "terrain_forest_moisture_threshold", self.terrain_forest_moisture_threshold, ), ] { if !value.is_finite() { return Err(format!("{name} must be finite")); } } Ok(()) } fn validate_amounts(&self) -> Result<(), String> { if self.birth_food_cost > self.birth_food_threshold { return Err("birth_food_cost cannot exceed birth_food_threshold".into()); } for (name, value) in [ ("initial_person_hunger", self.initial_person_hunger), ("initial_animal_energy", self.initial_animal_energy), ( "animal_energy_death_threshold", self.animal_energy_death_threshold, ), ("daylight_night_threshold", self.daylight_night_threshold), ("daylight_base", self.daylight_base), ("daylight_amplitude", self.daylight_amplitude), ("hunger_night_at_home", self.hunger_night_at_home), ("hunger_other", self.hunger_other), ("hunger_eat_home_threshold", self.hunger_eat_home_threshold), ( "hunger_eat_carried_threshold", self.hunger_eat_carried_threshold, ), ("hunger_return_threshold", self.hunger_return_threshold), ( "hunger_starvation_threshold", self.hunger_starvation_threshold, ), ("hunger_meal_reduction", self.hunger_meal_reduction), ("rabbit_energy_drain", self.rabbit_energy_drain), ("rabbit_energy_regen", self.rabbit_energy_regen), ("rabbit_energy_max", self.rabbit_energy_max), ("wolf_energy_drain", self.wolf_energy_drain), ("wolf_hunt_energy_gain", self.wolf_hunt_energy_gain), ("wolf_energy_max", self.wolf_energy_max), ("rabbit_breed_energy", self.rabbit_breed_energy), ("rabbit_breed_energy_cost", self.rabbit_breed_energy_cost), ("rabbit_newborn_energy", self.rabbit_newborn_energy), ("wolf_breed_energy", self.wolf_breed_energy), ("wolf_breed_energy_cost", self.wolf_breed_energy_cost), ("wolf_newborn_energy", self.wolf_newborn_energy), ] { if !value.is_finite() || value < 0.0 { return Err(format!("{name} must be a finite non-negative number")); } } Ok(()) } } const CALENDAR_FIELDS: &[&str] = &[ "ticks_per_day", "days_per_week", "weeks_per_month", "months_per_year", "toddler", "child", "teen", "young_adult", "adult", "older_adult", "elderly", ]; /// `SimulationConfig` fields (except `calendar`), each read from the `pub const` /// of the same name in upper case. const CONFIG_FIELDS: &[&str] = &[ "width", "height", "initial_population", "initial_young_adults", "initial_rabbits", "initial_wolves", "initial_food", "initial_person_hunger", "initial_animal_energy", "animal_energy_death_threshold", "starting_tick_fraction", "population_capacity", "house_capacity", "max_houses", "daylight_night_threshold", "daylight_base", "daylight_amplitude", "home_radius", "attack_range", "entity_update_interval", "hunger_night_at_home", "hunger_other", "hunger_eat_home_threshold", "hunger_eat_carried_threshold", "hunger_return_threshold", "hunger_starvation_threshold", "hunger_meal_reduction", "food_per_meal", "resource_search_radius", "person_hunt_success_chance", "person_hunt_food_yield", "person_gather_yield", "person_tooled_gather_yield", "partnership_distance", "birth_food_threshold", "birth_food_cost", "tool_wood_cost", "tool_stone_cost", "house_wood_cost", "house_stone_cost", "house_placement_radius", "resource_regrow_attempts", "initial_tree_chance", "initial_tree_amount", "initial_berry_chance", "initial_berry_amount", "initial_stone_chance", "initial_stone_amount", "tree_regrow_chance", "tree_regrow_amount", "berry_regrow_chance", "berry_regrow_amount", "stone_regrow_chance", "stone_regrow_amount", "animal_population_interval", "rabbit_spawn_cap", "wolf_spawn_cap", "wolf_spawn_rabbit_threshold", "wolf_min_home_distance", "rabbit_energy_drain", "rabbit_energy_regen", "rabbit_energy_max", "rabbit_wolf_detection_range", "rabbit_breed_energy", "rabbit_max_population", "rabbit_breed_chance", "rabbit_breed_energy_cost", "rabbit_newborn_energy", "wolf_energy_drain", "wolf_prey_range", "wolf_hunt_success_chance", "wolf_hunt_energy_gain", "wolf_energy_max", "wolf_breed_energy", "wolf_max_population", "wolf_breed_chance", "wolf_breed_energy_cost", "wolf_newborn_energy", "terrain_noise_octaves", "terrain_noise_scale", "terrain_edge_height", "terrain_edge_falloff", "terrain_edge_distance_cap", "terrain_moisture_offset", "terrain_moisture_seed_offset", "terrain_deep_water_threshold", "terrain_water_threshold", "terrain_sand_threshold", "terrain_forest_grass_threshold", "terrain_hills_threshold", "terrain_forest_moisture_threshold", "random_land_attempts", "log_entry_limit", "wander_attempts", "wander_radius", "manual_spawn_limit", "max_simulation_speed", "max_stock_edit", "max_person_inventory_edit", "max_hunger_edit", "max_animal_energy_edit", "max_resource_edit", ]; const CONFIG_ACCESSOR: &str = "__sim_config"; /// Rune does not expose unit constants to the host, so a generated function /// gathers the script's `pub const` items into one object. fn config_accessor_source() -> String { let fields = |names: &[&str]| { names .iter() .map(|name| format!("{name}: {}", name.to_uppercase())) .collect::>() .join(", ") }; format!( "pub fn {CONFIG_ACCESSOR}() {{ #{{ {}, calendar: #{{ {} }} }} }}", fields(CONFIG_FIELDS), fields(CALENDAR_FIELDS) ) } /// A compiled simulation script: `pub const` defaults plus `pub fn` behaviours. #[derive(Clone)] pub struct SimScript { source: String, config: SimulationConfig, runtime: Arc, unit: Arc, } impl SimScript { /// Compiles the script, reads and validates its constants, and smoke-tests /// every behaviour so errors surface at load time. pub fn load(src: &str) -> Result { let mut module = Module::new(); module.ty::().map_err(|e| e.to_string())?; let mut context = Context::with_default_modules().map_err(|e| e.to_string())?; context.install(module).map_err(|e| e.to_string())?; let mut sources = Sources::new(); for (name, text) in [ ("config", src.to_string()), ("generated", config_accessor_source()), ] { sources .insert(Source::new(name, text).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; } let mut diagnostics = Diagnostics::new(); let unit = rune::prepare(&mut sources) .with_context(&context) .with_diagnostics(&mut diagnostics) .build(); let Ok(unit) = unit else { let mut buffer = Buffer::no_color(); let _ = diagnostics.emit(&mut buffer, &sources); return Err(String::from_utf8_lossy(buffer.as_slice()).into_owned()); }; let runtime = Arc::new(context.runtime().map_err(|e| e.to_string())?); let unit = Arc::new(unit); let mut vm = Vm::new(Arc::clone(&runtime), Arc::clone(&unit)); let value = vm .call([CONFIG_ACCESSOR], ()) .map_err(|e| format!("reading constants: {e}"))?; let config = rune::from_value::(value) .map_err(|e| format!("invalid constant: {e}"))?; config.validate()?; for f in ["person", "rabbit", "wolf"] { let v = vm .call([f], (Ctx::default(),)) .map_err(|e| format!("{f}(): {e}"))?; rune::from_value::(v).map_err(|_| format!("{f}() must return a string"))?; } Ok(Self { source: src.to_string(), config, runtime, unit, }) } pub fn source(&self) -> &str { &self.source } fn vm(&self) -> Vm { Vm::new(Arc::clone(&self.runtime), Arc::clone(&self.unit)) } } fn default_script() -> SimScript { static SCRIPT: OnceLock = OnceLock::new(); SCRIPT .get_or_init(|| SimScript::load(DEFAULT_SCRIPT).expect("config.rn failed to load")) .clone() } fn default_config() -> SimulationConfig { default_script().config } 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); self.weeks_per_month = self.weeks_per_month.clamp(1, 100); self.months_per_year = self.months_per_year.clamp(1, 100); for stage in [ "toddler", "child", "teen", "young_adult", "adult", "older_adult", "elderly", ] { let range = self.duration_mut(stage).expect("known life stage"); range.min_days = range.min_days.clamp(1, 1_000_000); range.max_days = range.max_days.clamp(range.min_days, 1_000_000); } } fn duration_mut(&mut self, stage: &str) -> Option<&mut StageDuration> { match stage { "toddler" => Some(&mut self.toddler), "child" => Some(&mut self.child), "teen" => Some(&mut self.teen), "young_adult" => Some(&mut self.young_adult), "adult" => Some(&mut self.adult), "older_adult" => Some(&mut self.older_adult), "elderly" => Some(&mut self.elderly), _ => None, } } } #[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; const GRASS: u8 = 3; const FOREST: u8 = 4; const HILLS: u8 = 5; const MOUNTAIN: u8 = 6; const TREE: u8 = 1; const BERRY: u8 = 2; const STONE: u8 = 3; pub struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { let mut x = self.0; x ^= x >> 12; x ^= x << 25; x ^= x >> 27; self.0 = x; x.wrapping_mul(0x2545_F491_4F6C_DD1D) } fn f(&mut self) -> f32 { unit_f32(self.next()) } fn chance(&mut self, p: f32) -> bool { self.f() < p } fn below(&mut self, n: usize) -> usize { let n = u64::try_from(n).expect("usize fits in u64"); usize::try_from(self.next() % n).expect("value below a usize bound fits in usize") } } /// Maps the top 24 bits of `bits` to `[0, 1)`. #[allow(clippy::cast_precision_loss)] fn unit_f32(bits: u64) -> f32 { // Values below 2^24 are exactly representable in f32. (bits >> 40) as f32 / (1u64 << 24) as f32 } /// Converts a map coordinate or dimension (at most 256) to `f32` exactly. fn coord_f32(n: usize) -> f32 { f32::from(u16::try_from(n).expect("map coordinates fit in u16")) } /// Parses the integer argument at `i` of an edit command. fn arg(t: &[&str], i: usize) -> Option { t.get(i).and_then(|s| s.parse().ok()) } /// Clamps `v` into `lo..=hi` and converts it; the bounds must fit in `T`. fn clamp_to>(v: i64, lo: i64, hi: i64) -> T where T::Error: std::fmt::Debug, { T::try_from(v.clamp(lo, hi)).expect("edit bounds fit the target type") } /// Converts an edit value to `f32`, rounding values beyond 2^24 like `as` does. #[allow(clippy::cast_precision_loss)] fn small_f32(v: i64) -> f32 { v as f32 } /// Sign of the step from `from` towards `to` along one axis. fn direction(from: usize, to: usize) -> isize { match to.cmp(&from) { std::cmp::Ordering::Greater => 1, std::cmp::Ordering::Less => -1, std::cmp::Ordering::Equal => 0, } } fn hash(x: i32, y: i32, seed: u64) -> f32 { let mut h = i64::from(x) .cast_unsigned() .wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ i64::from(y) .cast_unsigned() .wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ seed.wrapping_mul(0x1656_67B1_9E37_79F9); h ^= h >> 33; h = h.wrapping_mul(0xFF51_AFD7_ED55_8CCD); h ^= h >> 33; unit_f32(h) } /// Lattice cell containing `v`. #[allow(clippy::cast_possible_truncation)] fn lattice(v: f32) -> i32 { // Noise inputs are bounded map coordinates; `as` saturates for anything larger. v.floor() as i32 } fn value_noise(x: f32, y: f32, seed: u64) -> f32 { let smooth = |t: f32| t * t * (3.0 - 2.0 * t); let (xi, yi) = (lattice(x), lattice(y)); let (u, v) = (smooth(x - x.floor()), smooth(y - y.floor())); let top_left = hash(xi, yi, seed); let top_right = hash(xi + 1, yi, seed); let bottom_left = hash(xi, yi + 1, seed); let bottom_right = hash(xi + 1, yi + 1, seed); let top = top_left + (top_right - top_left) * u; let bottom = bottom_left + (bottom_right - bottom_left) * u; top + (bottom - top) * v } fn fbm(x: f32, y: f32, seed: u64, octaves: usize, scale: f32) -> f32 { let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / scale, 0.0); for o in 0..octaves { sum += amp * value_noise(x * freq, y * freq, seed + o as u64); norm += amp; amp *= 0.5; freq *= 2.0; } sum / norm } /// What an entity senses this tick; passed to the Rune behaviour scripts. // A flat record of independent sensor flags keeps field access simple in `config.rn`. #[allow(clippy::struct_excessive_bools)] #[derive(Any, Default, Clone)] struct Ctx { #[rune(get)] night: bool, #[rune(get)] at_home: bool, #[rune(get)] hunger: f64, #[rune(get)] carry: i64, #[rune(get)] carried_food: i64, #[rune(get)] stock_food: i64, #[rune(get)] stock_wood: i64, #[rune(get)] stock_stone: i64, #[rune(get)] berry_near: bool, #[rune(get)] tree_near: bool, #[rune(get)] stone_near: bool, #[rune(get)] rabbit_near: bool, #[rune(get)] wolf_near: bool, #[rune(get)] prey_near: bool, #[rune(get)] roll: f64, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Person { id: u32, x: usize, y: usize, hunger: f32, food: u32, wood: u32, 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, /// State of the person's own random stream, used for their life-stage durations. #[serde(default)] rng: u64, } fn default_stage_duration_days() -> u32 { 1 } impl Person { fn carry(&self) -> u32 { self.food + self.wood + self.stone } fn position(&self) -> (usize, usize) { (self.x, self.y) } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Animal { x: usize, y: usize, energy: f32, dead: bool, } impl Animal { fn position(&self) -> (usize, usize) { (self.x, self.y) } } #[derive(Clone, Debug, Serialize, Deserialize)] struct Partnership { id: u32, first_person_id: u32, second_person_id: u32, formed_tick: u64, last_birth_tick: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Species { Rabbit, Wolf, } impl Species { fn kind(self) -> Kind { match self { Species::Rabbit => Kind::Rabbit, Species::Wolf => Kind::Wolf, } } } /// How long a departed entity's actor keeps forwarding messages to the shard it moved to. const FORWARDING_TICKS: u64 = 100; /// An entity that moved to another shard and whose old actor still forwards its messages. struct Departure { key: EntityKey, shard: usize, tick: u64, actor: LocalActor, } /// What changed in a shard during its steps, for the gateway to maintain the cluster directory. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct StepReport { /// Entities handed to another shard. pub moved: Vec<(EntityKey, usize)>, /// Entities that were born on, spawned on or moved into this shard. pub arrived: Vec, /// Entities that died or were removed. pub left: Vec, } /// Reusable A* buffers; `visited` marks which entries belong to the current search. #[derive(Default)] struct PathScratch { search: u32, visited: Vec, costs: Vec, previous: Vec, frontier: BinaryHeap>, } /// The entity state observed from the actors at the start of a step, kept up to date with /// their replies while the step runs. It is a read model only; the actors own the state. #[derive(Default)] struct Census { people: Vec, rabbits: Vec<(AnimalId, Animal)>, wolves: Vec<(AnimalId, Animal)>, } impl Census { fn animals_mut(&mut self, species: Species) -> &mut Vec<(AnimalId, Animal)> { match species { Species::Rabbit => &mut self.rabbits, Species::Wolf => &mut self.wolves, } } } /// World-level state (terrain, resources, the shared stockpile, houses, calendar and the world /// random stream) lives here. Every person, animal and partnership is a Picard actor that owns /// its own state; the world only coordinates them, because the Rune VM that decides behaviours /// cannot leave the simulation thread. pub struct World { seed: u64, width: usize, height: usize, tiles: Vec, res: Vec<(u8, u16)>, person_actors: BTreeMap>, rabbit_actors: BTreeMap>, wolf_actors: BTreeMap>, partnership_actors: BTreeMap>, next_animal_id: AnimalId, next_partnership_id: u32, mailbox: Mailbox, actor_system: ActorSystem, shard: Shard, router: Arc, departed: Vec, report: StepReport, paths: PathScratch, houses: Vec<(usize, usize)>, home: (usize, usize), food: u32, wood: u32, stone: u32, tools: u32, rng: Rng, tick: u64, next_id: u32, log: Vec, script: SimScript, vm: Vm, pub paused: bool, pub speed: u32, pub terrain_rev: u32, calendar: CalendarSettings, config: SimulationConfig, } #[derive(Serialize, Deserialize)] struct WorldSnapshot { version: u32, seed: Option, #[serde(default = "default_width")] width: usize, #[serde(default = "default_height")] height: usize, tiles: Vec, res: Vec<(u8, u16)>, people: Vec, rabbits: Vec, wolves: Vec, houses: Vec<(usize, usize)>, home: (usize, usize), food: u32, wood: u32, stone: u32, tools: u32, rng: u64, tick: u64, next_id: u32, log: Vec, paused: bool, speed: u32, terrain_rev: u32, #[serde(default)] calendar: CalendarSettings, #[serde(default)] partnerships: Vec, #[serde(default = "default_next_partnership_id")] next_partnership_id: u32, #[serde(default)] next_animal_id: Option, #[serde(default)] shard: Option, } fn default_next_partnership_id() -> u32 { 1 } fn default_width() -> usize { W } fn default_height() -> usize { H } fn cheb(a: (usize, usize), b: (usize, usize)) -> usize { a.0.abs_diff(b.0).max(a.1.abs_diff(b.1)) } fn sample_stage_duration(calendar: &CalendarSettings, stage: LifeStage, rng: &mut Rng) -> u32 { let duration = match stage { LifeStage::Toddler => calendar.toddler, LifeStage::Child => calendar.child, LifeStage::Teen => calendar.teen, LifeStage::YoungAdult => calendar.young_adult, LifeStage::Adult => calendar.adult, LifeStage::OlderAdult => calendar.older_adult, LifeStage::Elderly => 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 + u32::try_from(rng.below(range as usize)).expect("sample is below a u32 range") } /// Drops partnerships with a member that is not `alive` and clears the partner links of /// `people` in them; returns the dropped partnerships. With `clear_orphans`, which needs every /// partnership to be known, links without any partnership are cleared too. fn repair_partnerships( people: &mut [Person], partnerships: &mut Vec, alive: impl Fn(u32) -> bool, clear_orphans: bool, ) -> Vec { let (kept, dissolved): (Vec<_>, Vec<_>) = partnerships.drain(..).partition(|partnership| { alive(partnership.first_person_id) && alive(partnership.second_person_id) }); *partnerships = kept; let pairs = |partnerships: &[Partnership]| -> HashSet<(u32, u32)> { partnerships .iter() .map(|partnership| (partnership.first_person_id, partnership.second_person_id)) .flat_map(|(first, second)| [(first, second), (second, first)]) .collect() }; let partnered = pairs(partnerships); let broken = pairs(&dissolved); for person in people { if person.partner_id.is_some_and(|partner| { broken.contains(&(person.id, partner)) || (clear_orphans && !partnered.contains(&(person.id, partner))) }) { person.partner_id = None; } } dissolved } /// Collects one reply per actor and sorts them by key, so the simulation stays deterministic /// whatever order the actors answered in. fn sorted_by_key(mut items: Vec<(K, T)>) -> Vec<(K, T)> { items.sort_by(|a, b| a.0.cmp(&b.0)); items } impl World { #[cfg(test)] pub fn new(seed: u64) -> World { Self::new_with_script(seed, default_script()) } fn generate_terrain(seed: u64, config: &SimulationConfig) -> Vec { let (width, height) = (config.width, config.height); let mut tiles = vec![0u8; width * height]; for y in 0..height { for x in 0..width { let (fx, fy) = (coord_f32(x), coord_f32(y)); let dx = (fx / coord_f32(width) - 0.5) * 2.0; let dy = (fy / coord_f32(height) - 0.5) * 2.0; let d = (dx * dx + dy * dy).min(config.terrain_edge_distance_cap); let h = fbm( fx, fy, seed, config.terrain_noise_octaves, config.terrain_noise_scale, ) + config.terrain_edge_height - config.terrain_edge_falloff * d; let m = fbm( fx + config.terrain_moisture_offset, fy + config.terrain_moisture_offset, seed.wrapping_add(config.terrain_moisture_seed_offset), config.terrain_noise_octaves, config.terrain_noise_scale, ); tiles[y * width + x] = if h < config.terrain_deep_water_threshold { WATER_DEEP } else if h < config.terrain_water_threshold { WATER } else if h < config.terrain_sand_threshold { SAND } else if h < config.terrain_forest_grass_threshold { if m > config.terrain_forest_moisture_threshold { FOREST } else { GRASS } } else if h < config.terrain_hills_threshold { HILLS } else { MOUNTAIN }; } } tiles } fn populate(&mut self) { let young_adults = self.shard.share(self.config.initial_young_adults); for i in 0..self.shard.share(self.config.initial_population) { let mut person = self.new_person(self.home); let stage = if i < young_adults { LifeStage::YoungAdult } else { LifeStage::Adult }; self.make_grown_up(&mut person, stage); self.spawn_person_actor(person); } for _ in 0..self.shard.share(self.config.initial_rabbits) { if let Some(p) = self.random_land() { let energy = self.config.initial_animal_energy; self.spawn_animal(Species::Rabbit, p, energy); } } for _ in 0..self.shard.share(self.config.initial_wolves) { if let Some(p) = self.random_land() && cheb(p, self.home) > self.config.wolf_min_home_distance { let energy = self.config.initial_animal_energy; self.spawn_animal(Species::Wolf, p, energy); } } } /// Gives `person` the age of someone who has lived through every stage before `stage`. fn make_grown_up(&mut self, person: &mut Person, stage: LifeStage) { 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| u64::from(self.sample_stage_duration(past_stage))) .sum(); 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 empty( seed: u64, script: SimScript, tiles: Vec, res: Vec<(u8, u16)>, shard: Shard, ) -> World { let config = script.config.clone(); let (width, height) = (config.width, config.height); let actor_system = ActorSystem::new().expect("failed to start Picard actor system"); let first_id = shard.first_id(); World { seed, width, height, tiles, res, person_actors: BTreeMap::new(), rabbit_actors: BTreeMap::new(), wolf_actors: BTreeMap::new(), partnership_actors: BTreeMap::new(), next_animal_id: first_id, next_partnership_id: u32::try_from(first_id).expect("shard counts are small"), mailbox: Mailbox::new(&actor_system), router: Router::new(shard, &actor_system), actor_system, shard, departed: Vec::new(), report: StepReport::default(), paths: PathScratch::default(), houses: vec![], home: (width / 2, height / 2), food: u32::try_from(shard.share(config.initial_food as usize)) .expect("a share is at most the total"), wood: 0, stone: 0, tools: 0, rng: Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1), tick: (config.calendar.ticks_per_day / config.starting_tick_fraction).max(1), next_id: u32::try_from(first_id).expect("shard counts are small"), log: vec![], vm: script.vm(), script, paused: false, speed: 1, terrain_rev: 0, calendar: config.calendar.clone(), config, } } #[cfg(test)] pub fn new_with_script(seed: u64, script: SimScript) -> World { Self::new_shard(seed, script, Shard::SINGLE) } /// Creates the part of the world owned by `shard`. Every shard generates the same terrain /// and resources from `seed`, then keeps only the resources in its own columns. pub fn new_shard(seed: u64, script: SimScript, shard: Shard) -> World { let tiles = Self::generate_terrain(seed, &script.config); let res = vec![(0, 0); tiles.len()]; let mut w = Self::empty(seed, script, tiles, res, shard); let (width, height) = (w.width, w.height); let centre = w.region_centre(); w.home = w .find_land(GRASS) .unwrap_or_else(|| w.find_land(SAND).unwrap_or(centre)); for i in 0..width * height { let r = w.rng.f(); w.res[i] = match w.tiles[i] { FOREST if r < w.config.initial_tree_chance => (TREE, w.config.initial_tree_amount), GRASS if r < w.config.initial_berry_chance => { (BERRY, w.config.initial_berry_amount) } HILLS if r < w.config.initial_stone_chance => { (STONE, w.config.initial_stone_amount) } _ => (0, 0), }; } let columns = w.columns(); for (i, res) in w.res.iter_mut().enumerate() { if !columns.contains(&(i % width)) { *res = (0, 0); } } let (hx, hy) = w.home; w.res[hy * width + hx] = (0, 0); w.rng.0 ^= (shard.index as u64).wrapping_mul(0xD1B5_4A32_D192_ED03); w.rng.0 |= 1; w.populate(); w } /// The columns of the map this shard simulates. fn columns(&self) -> Range { self.shard.columns(self.width) } fn owns(&self, (x, _): (usize, usize)) -> bool { self.columns().contains(&x) } fn region_centre(&self) -> (usize, usize) { let columns = self.columns(); (columns.start.midpoint(columns.end), self.height / 2) } fn take_person_id(&mut self) -> u32 { let id = self.next_id; self.next_id += u32::try_from(self.shard.id_stride()).expect("shard counts are small"); id } fn take_partnership_id(&mut self) -> u32 { let id = self.next_partnership_id; self.next_partnership_id += u32::try_from(self.shard.id_stride()).expect("shard counts are small"); id } /// Records an entity appearing on this shard, unless it is the only one. fn report_arrival(&mut self, key: EntityKey) { if self.shard.count > 1 { self.report.arrived.push(key); } } fn report_departure(&mut self, key: EntityKey) { if self.shard.count > 1 { self.report.left.push(key); } } fn spawn_person_actor(&mut self, person: Person) { let id = person.id; let actor = self .router .spawn_person(person) .expect("failed to create Picard person actor"); self.person_actors.insert(id, actor); self.report_arrival(EntityKey::person(id)); } fn animal_actors(&self, species: Species) -> &BTreeMap> { match species { Species::Rabbit => &self.rabbit_actors, Species::Wolf => &self.wolf_actors, } } fn animal_actors_mut( &mut self, species: Species, ) -> &mut BTreeMap> { match species { Species::Rabbit => &mut self.rabbit_actors, Species::Wolf => &mut self.wolf_actors, } } fn spawn_animal_actor(&mut self, species: Species, animal: Animal) -> AnimalId { let id = self.next_animal_id; self.next_animal_id += self.shard.id_stride(); let actor = self .router .spawn_animal(species.kind(), id, animal) .expect("failed to create Picard animal actor"); self.animal_actors_mut(species).insert(id, actor); self.report_arrival(EntityKey::new(species.kind(), id)); id } fn spawn_animal( &mut self, species: Species, (x, y): (usize, usize), energy: f32, ) -> (AnimalId, Animal) { let animal = Animal { x, y, energy, dead: false, }; (self.spawn_animal_actor(species, animal.clone()), animal) } fn spawn_partnership_actor(&mut self, partnership: Partnership) { let id = partnership.id; let actor = self .router .spawn_partnership(partnership) .expect("failed to create Picard partnership actor"); self.partnership_actors.insert(id, actor); } fn retire_person(&mut self, id: u32) { if let Some(actor) = self.person_actors.remove(&id) { let key = EntityKey::person(id); self.router.unregister(key); LocalActor::Person(actor).stop(); self.report_departure(key); } } fn retire_animal(&mut self, species: Species, id: AnimalId) { if let Some(actor) = self.animal_actors_mut(species).remove(&id) { let key = EntityKey::new(species.kind(), id); self.router.unregister(key); LocalActor::Animal(actor).stop(); self.report_departure(key); } } fn retire_partnership(&mut self, id: u32) { if let Some(actor) = self.partnership_actors.remove(&id) { self.router .unregister(EntityKey::new(Kind::Partnership, u64::from(id))); LocalActor::Partnership(actor).stop(); } } /// Takes in the entities other shards handed over since the last step. fn receive_arrivals(&mut self) { for arrival in self.router.take_arrivals() { let key = match arrival { Arrival::Person(person, actor) => { self.person_actors.insert(person.id, actor); self.say(&format!("Person #{} arrived from afar", person.id)); EntityKey::person(person.id) } Arrival::Animal(kind, id, actor) => { let species = if kind == Kind::Wolf { Species::Wolf } else { Species::Rabbit }; self.animal_actors_mut(species).insert(id, actor); EntityKey::new(kind, id) } }; self.departed.retain(|departure| departure.key != key); self.report_arrival(key); } } /// Stops the actors of entities that left long enough ago for every shard to know where /// they went; the router keeps forwarding their messages from then on. fn retire_departures(&mut self) { let tick = self.tick; let (expired, waiting): (Vec<_>, Vec<_>) = self .departed .drain(..) .partition(|departure| tick.saturating_sub(departure.tick) >= FORWARDING_TICKS); self.departed = waiting; for departure in expired { if self.router.retire_forwarder(departure.key, departure.shard) { departure.actor.stop(); } } } /// Hands every entity standing outside this shard's columns to the shard that owns its /// position. The entities' actors pass their whole state on themselves. fn transfer_out(&mut self, census: &Census) { if self.shard.count == 1 { return; } let request = self.mailbox.request(); let mut leaving = Vec::new(); for person in &census.people { if !self.owns(person.position()) && let Some(actor) = self.person_actors.remove(&person.id) { let shard = self.shard.owner(person.x, self.width); actor.tell(PersonMsg::Depart(request, shard), self.mailbox.reply_to()); leaving.push(( EntityKey::person(person.id), shard, LocalActor::Person(actor), )); } } for species in [Species::Rabbit, Species::Wolf] { let animals = match species { Species::Rabbit => &census.rabbits, Species::Wolf => &census.wolves, }; for (id, animal) in animals { if !self.owns(animal.position()) && let Some(actor) = self.animal_actors_mut(species).remove(id) { let shard = self.shard.owner(animal.x, self.width); actor.tell(AnimalMsg::Depart(request, shard), self.mailbox.reply_to()); let key = EntityKey::new(species.kind(), *id); leaving.push((key, shard, LocalActor::Animal(actor))); } } } self.mailbox.collect(request, leaving.len()); for (key, shard, actor) in leaving { self.report.moved.push((key, shard)); self.departed.push(Departure { key, shard, tick: self.tick, actor, }); } } /// Sends one message to every actor in `actors` and waits for all replies. fn broadcast( &self, actors: &BTreeMap>, message: impl Fn(RequestId) -> M, ) -> Vec { if actors.is_empty() { return Vec::new(); } let request = self.mailbox.request(); for actor in actors.values() { actor.tell(message(request), self.mailbox.reply_to()); } self.mailbox.collect(request, actors.len()) } fn observe_people(&self) -> Vec { let people = self .broadcast(&self.person_actors, PersonMsg::Observe) .into_iter() .map(|reply| match reply { ReplyBody::Person(person) => (person.id, person), other => panic!("person actor answered an observation with {other:?}"), }) .collect(); sorted_by_key(people).into_iter().map(|(_, p)| p).collect() } fn observe_animals(&self, species: Species) -> Vec<(AnimalId, Animal)> { let animals = self .broadcast(self.animal_actors(species), AnimalMsg::Observe) .into_iter() .map(|reply| match reply { ReplyBody::Animal(id, animal) => (id, animal), other => panic!("animal actor answered an observation with {other:?}"), }) .collect(); sorted_by_key(animals) } fn observe_partnerships(&self) -> Vec { let partnerships = self .broadcast(&self.partnership_actors, PartnershipMsg::Observe) .into_iter() .map(|reply| match reply { ReplyBody::Partnership(partnership) => (partnership.id, partnership), other => panic!("partnership actor answered an observation with {other:?}"), }) .collect(); sorted_by_key(partnerships) .into_iter() .map(|(_, p)| p) .collect() } fn census(&self) -> Census { Census { people: self.observe_people(), rabbits: self.observe_animals(Species::Rabbit), wolves: self.observe_animals(Species::Wolf), } } fn tell_person(&self, id: u32, message: PersonMsg) { self.router.tell(EntityKey::person(id), message); } /// Sends a request to one actor and waits for its reply. fn ask( &self, actor: &ActorRef, message: impl FnOnce(RequestId) -> M, ) -> ReplyBody { self.mailbox .ask(|request, sender: Sender| actor.tell(message(request), sender)) } fn find_land(&self, kind: u8) -> Option<(usize, usize)> { let c = self.region_centre(); let mut best: Option<(usize, usize)> = None; for y in 0..self.height { for x in self.columns() { if self.tiles[y * self.width + x] == kind && best.is_none_or(|b| cheb((x, y), c) < cheb(b, c)) { best = Some((x, y)); } } } best } fn random_land(&mut self) -> Option<(usize, usize)> { let columns = self.columns(); for _ in 0..self.config.random_land_attempts { let x = columns.start + self.rng.below(columns.len()); let y = self.rng.below(self.height); if self.walkable((x, y)) { return Some((x, y)); } } None } fn new_person(&mut self, (x, y): (usize, usize)) -> Person { let stage_duration_days = self.sample_stage_duration(LifeStage::Toddler); Person { id: self.take_person_id(), x, y, hunger: self.config.initial_person_hunger, food: 0, wood: 0, stone: 0, action: "idle".to_string(), dead: false, age_ticks: 0, stage: LifeStage::Toddler, stage_elapsed_ticks: 0, stage_duration_days, partner_id: None, rng: self.rng.next() | 1, } } fn spawn_person_at(&mut self, position: (usize, usize)) -> Person { let person = self.new_person(position); self.spawn_person_actor(person.clone()); person } fn sample_stage_duration(&mut self, stage: LifeStage) -> u32 { sample_stage_duration(&self.calendar, stage, &mut self.rng) } fn population_u32(&self) -> u32 { u32::try_from(self.person_actors.len()).unwrap_or(u32::MAX) } fn say(&mut self, s: &str) { self.log.push(format!( "[day {}] {}", self.tick / self.calendar.ticks_per_day + 1, s )); if self.log.len() > self.config.log_entry_limit { self.log.remove(0); } } /// Fraction of the current day that has elapsed, in `[0, 1)`. #[allow(clippy::cast_precision_loss)] fn day_fraction(&self) -> f32 { // `ticks_per_day` is validated to at most 1,000,000, which f32 represents exactly. let ticks_per_day = self.calendar.ticks_per_day; (self.tick % ticks_per_day) as f32 / ticks_per_day as f32 } pub fn daylight(&self) -> f32 { let phase = self.day_fraction(); let v = -(phase * std::f32::consts::TAU).cos(); (self.config.daylight_base + v * self.config.daylight_amplitude).clamp(0.0, 1.0) } fn walkable(&self, (x, y): (usize, usize)) -> bool { x < self.width && y < self.height && matches!( self.tiles[y * self.width + x], SAND | GRASS | FOREST | HILLS ) } /// The in-bounds tile at `(x + dx, y + dy)`, if any. fn offset(&self, (x, y): (usize, usize), dx: isize, dy: isize) -> Option<(usize, usize)> { let x = x.checked_add_signed(dx).filter(|&x| x < self.width)?; let y = y.checked_add_signed(dy).filter(|&y| y < self.height)?; Some((x, y)) } /// The walkable tile at `(x + dx, y + dy)`, excluding the origin itself. fn walk_to(&self, from: (usize, usize), dx: isize, dy: isize) -> Option<(usize, usize)> { if (dx, dy) == (0, 0) { return None; } self.offset(from, dx, dy).filter(|&to| self.walkable(to)) } fn move_dir(&mut self, x: usize, y: usize, dx: isize, dy: isize) -> (usize, usize) { [(dx, dy), (dx, 0), (0, dy)] .into_iter() .find_map(|(a, b)| self.walk_to((x, y), a, b)) .unwrap_or_else(|| self.wander(x, y)) } fn wander(&mut self, x: usize, y: usize) -> (usize, usize) { let radius = self.config.wander_radius; let diameter = radius * 2 + 1; let signed_radius = isize::try_from(radius).expect("wander radius is validated"); for _ in 0..self.config.wander_attempts { let a = isize::try_from(self.rng.below(diameter)).expect("bounded by diameter"); let b = isize::try_from(self.rng.below(diameter)).expect("bounded by diameter"); if let Some(to) = self.walk_to((x, y), a - signed_radius, b - signed_radius) { return to; } } (x, y) } fn move_toward(&mut self, from: (usize, usize), to: (usize, usize)) -> (usize, usize) { if from == to { return from; } let mut paths = std::mem::take(&mut self.paths); let step = self.find_path_step(&mut paths, from, to); self.paths = paths; step.unwrap_or_else(|| self.wander(from.0, from.1)) } /// A* search from `from` to `to`; returns the first step of a shortest path. fn find_path_step( &self, paths: &mut PathScratch, from: (usize, usize), to: (usize, usize), ) -> Option<(usize, usize)> { let tiles = self.width * self.height; if paths.visited.len() != tiles { *paths = PathScratch { visited: vec![0; tiles], costs: vec![0; tiles], previous: vec![0; tiles], ..PathScratch::default() }; } paths.search = paths.search.wrapping_add(1); if paths.search == 0 { paths.visited.fill(0); paths.search = 1; } let search = paths.search; let start = from.1 * self.width + from.0; let target = to.1 * self.width + to.0; paths.frontier.clear(); paths.frontier.push(Reverse((cheb(from, to), start))); paths.visited[start] = search; paths.costs[start] = 0; paths.previous[start] = start; while let Some(Reverse((_, current))) = paths.frontier.pop() { if current == target { let mut next = target; while paths.previous[next] != start { next = paths.previous[next]; } return Some((next % self.width, next / self.width)); } let position = (current % self.width, current / self.width); for dy in -1..=1 { for dx in -1..=1 { let Some((x, y)) = self.walk_to(position, dx, dy) else { continue; }; let neighbor = y * self.width + x; let cost = paths.costs[current] + 1; if paths.visited[neighbor] == search && cost >= paths.costs[neighbor] { continue; } paths.visited[neighbor] = search; paths.costs[neighbor] = cost; paths.previous[neighbor] = current; paths .frontier .push(Reverse((cost + cheb((x, y), to), neighbor))); } } } None } fn nearest_res(&self, from: (usize, usize), kind: u8, r: usize) -> Option<(usize, usize)> { let mut best: Option<(usize, usize)> = None; let (x0, x1) = ( from.0.saturating_sub(r), from.0.saturating_add(r).min(self.width - 1), ); let (y0, y1) = ( from.1.saturating_sub(r), from.1.saturating_add(r).min(self.height - 1), ); for y in y0..=y1 { for x in x0..=x1 { if self.res[y * self.width + x].0 == kind && best.is_none_or(|b| cheb((x, y), from) < cheb(b, from)) { best = Some((x, y)); } } } best } fn nearest_animal( animals: &[(AnimalId, Animal)], from: (usize, usize), r: usize, ) -> Option { let mut best: Option<(usize, usize)> = None; for (i, (_, a)) in animals.iter().enumerate() { let d = cheb(a.position(), from); if !a.dead && d <= r && best.is_none_or(|(_, bd)| d < bd) { best = Some((i, d)); } } best.map(|b| b.0) } pub fn step(&mut self) { self.tick += 1; self.receive_arrivals(); self.retire_departures(); self.regrow(); let mut census = Census { people: self.advance_people(), rabbits: self.observe_animals(Species::Rabbit), wolves: self.observe_animals(Species::Wolf), }; if self.tick.is_multiple_of(self.config.entity_update_interval) { self.tick_people(&mut census); self.tick_rabbits(&mut census); } else { self.tick_wolves(&mut census); } let new_day = self.tick.is_multiple_of(self.calendar.ticks_per_day); if self.retire_dead(&mut census) || (self.shard.count > 1 && new_day) { self.prune_partnerships(&mut census.people); } self.population(&mut census); self.transfer_out(&census); } /// Ages every person by one tick and returns them, logging stage changes and deaths. fn advance_people(&mut self) -> Vec { let calendar = self.calendar.clone(); let replies = self.broadcast(&self.person_actors, |request| { PersonMsg::Age(request, calendar.clone()) }); let aged = replies .into_iter() .map(|reply| match reply { ReplyBody::Aged(person, stage) => (person.id, (person, stage)), other => panic!("person actor answered ageing with {other:?}"), }) .collect(); let mut people = Vec::with_capacity(self.person_actors.len()); for (id, (person, stage)) in sorted_by_key(aged) { match stage { Some(LifeStage::Dead) => self.say(&format!("Person #{id} died of old age")), Some(stage) => self.say(&format!( "Person #{id} became a {}", stage.key().replace('_', " ") )), None => {} } people.push(person); } people } /// Stops the actors of dead entities; returns whether any person died. fn retire_dead(&mut self, census: &mut Census) -> bool { let dead_people: Vec = census .people .iter() .filter(|person| person.dead) .map(|person| person.id) .collect(); for &id in &dead_people { self.retire_person(id); } census.people.retain(|person| !person.dead); for species in [Species::Rabbit, Species::Wolf] { let dead: Vec = census .animals_mut(species) .iter() .filter(|(_, animal)| animal.dead) .map(|(id, _)| *id) .collect(); for id in dead { self.retire_animal(species, id); } census .animals_mut(species) .retain(|(_, animal)| !animal.dead); } !dead_people.is_empty() } /// Dissolves partnerships that lost a member and tells their survivors. fn prune_partnerships(&mut self, people: &mut [Person]) { let mut partnerships = self.observe_partnerships(); let partners_before: Vec> = people.iter().map(|p| p.partner_id).collect(); let local: HashSet = people.iter().map(|p| p.id).collect(); let router = Arc::clone(&self.router); let alive = |id: u32| local.contains(&id) || router.knows(EntityKey::person(id)); let dissolved = repair_partnerships(people, &mut partnerships, alive, self.shard.count == 1); for partnership in dissolved { self.retire_partnership(partnership.id); for member in [partnership.first_person_id, partnership.second_person_id] { if !local.contains(&member) && router.knows(EntityKey::person(member)) { self.tell_person(member, PersonMsg::SetPartner(None)); } } } for (person, partner_before) in people.iter().zip(partners_before) { if person.partner_id != partner_before { self.tell_person(person.id, PersonMsg::SetPartner(person.partner_id)); } } } fn regrow(&mut self) { let columns = self.columns(); for _ in 0..self.config.resource_regrow_attempts { let tile = self.rng.below(columns.len() * self.height); let i = (tile / columns.len()) * self.width + columns.start + tile % columns.len(); if self.res[i].0 != 0 { continue; } let p = self.rng.f(); match self.tiles[i] { FOREST if p < self.config.tree_regrow_chance => { self.res[i] = (TREE, self.config.tree_regrow_amount); } GRASS if p < self.config.berry_regrow_chance => { self.res[i] = (BERRY, self.config.berry_regrow_amount); } HILLS if p < self.config.stone_regrow_chance => { self.res[i] = (STONE, self.config.stone_regrow_amount); } _ => {} } } } fn population(&mut self, census: &mut Census) { let ticks_per_day = self.calendar.ticks_per_day; if self.tick.is_multiple_of(ticks_per_day) { self.find_partners(&mut census.people); } let ticks_per_week = ticks_per_day * u64::from(self.calendar.days_per_week); if self.tick.is_multiple_of(ticks_per_week) { self.have_children(ticks_per_week, census); } if self .tick .is_multiple_of(self.config.animal_population_interval) { let energy = self.config.initial_animal_energy; if census.rabbits.len() < self.shard.share(self.config.rabbit_spawn_cap) && let Some(p) = self.random_land() { let rabbit = self.spawn_animal(Species::Rabbit, p, energy); census.rabbits.push(rabbit); } if census.wolves.len() < self.shard.share(self.config.wolf_spawn_cap) && census.rabbits.len() > self.shard.share(self.config.wolf_spawn_rabbit_threshold) && let Some(p) = self.random_land() && cheb(p, self.home) > self.config.wolf_min_home_distance { let wolf = self.spawn_animal(Species::Wolf, p, energy); census.wolves.push(wolf); } } } fn find_partners(&mut self, people: &mut [Person]) { let ticks_per_week = self.calendar.ticks_per_day * u64::from(self.calendar.days_per_week); for first in 0..people.len() { if people[first].partner_id.is_some() || !people[first].stage.can_partner() || people[first].dead { continue; } let first_position = people[first].position(); let second = (0..people.len()) .filter(|&index| { index != first && people[index].partner_id.is_none() && people[index].stage.can_partner() && !people[index].dead }) .filter_map(|index| { let distance = cheb(first_position, people[index].position()); (distance <= self.config.partnership_distance).then_some((index, distance)) }) .min_by_key(|(_, distance)| *distance) .map(|(index, _)| index); let Some(second) = second else { continue; }; let first_id = people[first].id; let second_id = people[second].id; people[first].partner_id = Some(second_id); people[second].partner_id = Some(first_id); self.tell_person(first_id, PersonMsg::SetPartner(Some(second_id))); self.tell_person(second_id, PersonMsg::SetPartner(Some(first_id))); let id = self.take_partnership_id(); self.spawn_partnership_actor(Partnership { id, first_person_id: first_id, second_person_id: second_id, formed_tick: self.tick, last_birth_tick: self.tick / ticks_per_week * ticks_per_week, }); self.say(&format!( "People #{first_id} and #{second_id} became partners" )); } } fn have_children(&mut self, birth_interval_ticks: u64, census: &mut Census) { if self.food < self.config.birth_food_threshold || census.people.len() >= self.population_capacity() { return; } let tick = self.tick; let replies = self.broadcast(&self.partnership_actors, |request| { PartnershipMsg::BirthDue { request, tick, interval: birth_interval_ticks, } }); let due = replies .into_iter() .map(|reply| match reply { ReplyBody::BirthDue(partnership, due) => (partnership.id, (partnership, due)), other => panic!("partnership actor answered a birth check with {other:?}"), }) .collect(); for (partnership_id, (partnership, due)) in sorted_by_key(due) { if self.food < self.config.birth_food_threshold || census.people.len() >= self.population_capacity() { break; } if !due { continue; } let (first_id, second_id) = (partnership.first_person_id, partnership.second_person_id); let fertile = |id: u32| { census .people .iter() .find(|person| person.id == id) .and_then(|person| self.calendar.duration(person.stage.key())) .is_some_and(|duration| duration.fertile) }; if !(fertile(first_id) && fertile(second_id)) { continue; } self.food -= self.config.birth_food_cost; let toddler = self.spawn_person_at(self.home); census.people.push(toddler); if let Some(actor) = self.partnership_actors.get(&partnership_id) { actor.tell(PartnershipMsg::RecordBirth(self.tick), None); } self.say(&format!( "People #{first_id} and #{second_id} welcomed a toddler" )); } } fn population_capacity(&self) -> usize { self.shard.share(self.config.population_capacity) + self.houses.len() * self.config.house_capacity } 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()), Err(e) => { eprintln!("script error in {func}: {e}"); "wander".into() } } } fn tick_people(&mut self, census: &mut Census) { let performed = self.mailbox.request(); let mut performers = 0; for index in 0..census.people.len() { if census.people[index].dead { continue; } let Some(person) = self.meet_needs(&census.people[index]) else { continue; }; census.people[index] = person.clone(); if person.dead { self.say(&format!("Person #{} starved", person.id)); continue; } let activity = self.choose_activity(&person, census); if let Some(actor) = self.person_actors.get(&person.id) { actor.tell( PersonMsg::Perform(performed, activity), self.mailbox.reply_to(), ); performers += 1; } } for reply in self.mailbox.collect(performed, performers) { let ReplyBody::Person(person) = reply else { panic!("person actor answered an activity with {reply:?}"); }; if let Some(entry) = census.people.iter_mut().find(|p| p.id == person.id) { *entry = person; } } } /// Lets a person get hungry and eat, settling deposits and meals with the stockpile. fn meet_needs(&mut self, person: &Person) -> Option { let actor = self.person_actors.get(&person.id)?; let at_home = cheb(person.position(), self.home) <= self.config.home_radius; let night = self.daylight() < self.config.daylight_night_threshold; let needs = Needs { at_home, hunger_gain: if night && at_home { self.config.hunger_night_at_home } else { self.config.hunger_other }, stock_food: self.food, home_meal_threshold: self.config.hunger_eat_home_threshold, carried_meal_threshold: self.config.hunger_eat_carried_threshold, starvation_threshold: self.config.hunger_starvation_threshold, meal_reduction: self.config.hunger_meal_reduction, food_per_meal: self.config.food_per_meal, }; let reply = self.ask(actor, |request| PersonMsg::Needs(request, needs)); let ReplyBody::Fed(person, outcome) = reply else { panic!("person actor answered its needs with {reply:?}"); }; self.food += outcome.deposited.food; self.wood += outcome.deposited.wood; self.stone += outcome.deposited.stone; if outcome.ate_from_stock { self.food -= self.config.food_per_meal; } if at_home { self.craft_and_build(); } Some(person) } /// Harvests `kind` at `target` when standing on it, otherwise walks there. fn gather( &mut self, pos: (usize, usize), kind: u8, target: (usize, usize), ) -> ((usize, usize), Goods) { let idx = pos.1 * self.width + pos.0; if pos != target || self.res[idx].0 != kind { return (self.move_toward(pos, target), Goods::default()); } let yield_n: u16 = if self.tools >= self.population_u32() { self.config.person_tooled_gather_yield } else { self.config.person_gather_yield }; let take = yield_n.min(self.res[idx].1); self.res[idx].1 -= take; if self.res[idx].1 == 0 { self.res[idx].0 = 0; } let take = u32::from(take); let gathered = match kind { BERRY => Goods { food: take, ..Goods::default() }, TREE => Goods { wood: take, ..Goods::default() }, _ => Goods { stone: take, ..Goods::default() }, }; (pos, gathered) } /// Asks a rabbit actor to suffer a successful attack; returns whether it died of it. fn hunt_rabbit(&self, census: &mut Census, index: usize) -> bool { let (id, _) = census.rabbits[index]; let Some(actor) = self.rabbit_actors.get(&id) else { return false; }; let reply = self.ask(actor, AnimalMsg::Hunted); let ReplyBody::Hunted(_, killed) = reply else { panic!("rabbit actor answered a hunt with {reply:?}"); }; census.rabbits[index].1.dead = true; killed } /// Decides what a fed person does this tick using the `person` behaviour script. fn choose_activity(&mut self, p: &Person, census: &mut Census) -> Activity { let night = self.daylight() < self.config.daylight_night_threshold; let pos = p.position(); let home = self.home; let at_home = cheb(pos, home) <= self.config.home_radius; let activity = |action: &str, position| Activity { action: action.to_string(), position, gathered: Goods::default(), }; if p.hunger > self.config.hunger_return_threshold && self.food > 0 && !at_home { return activity("returning for food", self.move_toward(pos, home)); } if !p.stage.can_partner() { let position = if at_home { pos } else { self.move_toward(pos, home) }; return activity("growing up", position); } let berry = self.nearest_res(pos, BERRY, self.config.resource_search_radius); let tree = self.nearest_res(pos, TREE, self.config.resource_search_radius); let stone = self.nearest_res(pos, STONE, self.config.resource_search_radius); let rabbit = Self::nearest_animal(&census.rabbits, pos, self.config.resource_search_radius); let ctx = Ctx { night, at_home, hunger: f64::from(p.hunger), carry: i64::from(p.carry()), carried_food: i64::from(p.food), stock_food: i64::from(self.food), stock_wood: i64::from(self.wood), stock_stone: i64::from(self.stone), berry_near: berry.is_some(), tree_near: tree.is_some(), stone_near: stone.is_some(), rabbit_near: rabbit.is_some(), roll: f64::from(self.rng.f()), ..Default::default() }; let choice = self.decide("person", ctx); let gather = match choice.as_str() { "forage" => Some((BERRY, berry, "foraging")), "chop" => Some((TREE, tree, "chopping wood")), "mine" => Some((STONE, stone, "mining stone")), _ => None, }; match (choice.as_str(), gather, rabbit) { ("sleep", ..) => activity("sleeping", pos), ("go_home" | "haul" | "eat", ..) => { let action = match choice.as_str() { "go_home" => "going home", "haul" => "hauling goods", _ => "going to eat", }; let position = if at_home { pos } else { self.move_toward(pos, home) }; activity(action, position) } ("hunt", _, Some(rabbit)) => { let target = census.rabbits[rabbit].1.position(); if cheb(pos, target) > self.config.attack_range { return activity("hunting", self.move_toward(pos, target)); } let mut hunting = activity("hunting", pos); if self.rng.chance(self.config.person_hunt_success_chance) && self.hunt_rabbit(census, rabbit) { hunting.gathered.food = self.config.person_hunt_food_yield; } hunting } (_, Some((kind, Some(target), label)), _) => { let (position, gathered) = self.gather(pos, kind, target); Activity { action: label.to_string(), position, gathered, } } _ => activity("wandering", self.wander(pos.0, pos.1)), } } fn craft_and_build(&mut self) { if self.wood >= self.config.tool_wood_cost && self.stone >= self.config.tool_stone_cost && self.tools < self.population_u32() { self.wood -= self.config.tool_wood_cost; self.stone -= self.config.tool_stone_cost; self.tools += 1; } if self.wood >= self.config.house_wood_cost && self.stone >= self.config.house_stone_cost && self.houses.len() < self.config.max_houses { let (hx, hy) = self.home; let radius = self.config.house_placement_radius; let diameter = radius * 2 + 1; let x = (hx + self.rng.below(diameter)) .saturating_sub(radius) .min(self.width - 1); let y = (hy + self.rng.below(diameter)) .saturating_sub(radius) .min(self.height - 1); if self.walkable((x, y)) && self.res[y * self.width + x].0 == 0 && !self.houses.contains(&(x, y)) { self.wood -= self.config.house_wood_cost; self.stone -= self.config.house_stone_cost; self.houses.push((x, y)); self.say("A new house was built"); } } } /// Sends every animal of `species` its turn and returns the ids of those wanting to breed. fn live( &self, species: Species, census: &mut Census, turns: Vec<(AnimalId, AnimalTurn)>, ) -> Vec { let request = self.mailbox.request(); let mut sent = 0; for (id, turn) in turns { if let Some(actor) = self.animal_actors(species).get(&id) { actor.tell(AnimalMsg::Live(request, turn), self.mailbox.reply_to()); sent += 1; } } let lived = self .mailbox .collect(request, sent) .into_iter() .map(|reply| match reply { ReplyBody::Lived(id, animal, wants_to_breed) => (id, (animal, wants_to_breed)), other => panic!("animal actor answered its turn with {other:?}"), }) .collect(); let animals = census.animals_mut(species); let mut breeders = Vec::new(); for (id, (animal, wants_to_breed)) in sorted_by_key(lived) { if let Some(entry) = animals.iter_mut().find(|(key, _)| *key == id) { entry.1 = animal; } if wants_to_breed { breeders.push(id); } } breeders } /// Lets `breeders` give birth, in order, while the species is below `max_population`. fn breed( &mut self, species: Species, census: &mut Census, breeders: Vec, max_population: usize, cost: BirthCost, newborn_energy: f32, ) { for id in breeders { let living = census .animals_mut(species) .iter() .filter(|(_, animal)| !animal.dead) .count(); if living >= max_population { break; } let Some(actor) = self.animal_actors(species).get(&id) else { continue; }; let reply = self.ask(actor, |request| AnimalMsg::GiveBirth(request, cost)); let ReplyBody::Animal(_, parent) = reply else { panic!("animal actor answered a birth with {reply:?}"); }; let position = parent.position(); if let Some(entry) = census .animals_mut(species) .iter_mut() .find(|(key, _)| *key == id) { entry.1 = parent; } let newborn = self.spawn_animal(species, position, newborn_energy); census.animals_mut(species).push(newborn); } } fn tick_rabbits(&mut self, census: &mut Census) { let mut turns = Vec::with_capacity(census.rabbits.len()); for index in 0..census.rabbits.len() { let (id, rabbit) = census.rabbits[index].clone(); if rabbit.dead { continue; } let pos = rabbit.position(); let on_grass = matches!(self.tiles[pos.1 * self.width + pos.0], GRASS | FOREST); let wolf = Self::nearest_animal(&census.wolves, pos, self.config.rabbit_wolf_detection_range); let c = Ctx { wolf_near: wolf.is_some(), roll: f64::from(self.rng.f()), ..Default::default() }; let position = match (self.decide("rabbit", c).as_str(), wolf) { ("flee", Some(w)) => { let (wx, wy) = census.wolves[w].1.position(); self.move_dir(pos.0, pos.1, direction(wx, pos.0), direction(wy, pos.1)) } ("wander", _) => self.wander(pos.0, pos.1), _ => pos, }; let may_breed = self.rng.chance(self.config.rabbit_breed_chance); turns.push(( id, AnimalTurn { drain: self.config.rabbit_energy_drain, gain: on_grass.then_some(self.config.rabbit_energy_regen), max_energy: self.config.rabbit_energy_max, position, breed_above: may_breed.then_some(self.config.rabbit_breed_energy), death_threshold: self.config.animal_energy_death_threshold, }, )); } let breeders = self.live(Species::Rabbit, census, turns); let cost = BirthCost { energy: self.config.rabbit_breed_energy_cost, death_threshold: self.config.animal_energy_death_threshold, }; self.breed( Species::Rabbit, census, breeders, self.shard.share(self.config.rabbit_max_population), cost, self.config.rabbit_newborn_energy, ); } fn tick_wolves(&mut self, census: &mut Census) { let mut turns = Vec::with_capacity(census.wolves.len()); for index in 0..census.wolves.len() { let (id, wolf) = census.wolves[index].clone(); if wolf.dead { continue; } let pos = wolf.position(); let prey = Self::nearest_animal(&census.rabbits, pos, self.config.wolf_prey_range); let ctx = Ctx { prey_near: prey.is_some(), ..Default::default() }; let mut gain = None; let position = if let ("hunt", Some(rabbit)) = (self.decide("wolf", ctx).as_str(), prey) { let target = census.rabbits[rabbit].1.position(); if cheb(pos, target) <= self.config.attack_range { if self.rng.chance(self.config.wolf_hunt_success_chance) && self.hunt_rabbit(census, rabbit) { gain = Some(self.config.wolf_hunt_energy_gain); } pos } else { self.move_toward(pos, target) } } else { self.wander(pos.0, pos.1) }; let may_breed = self.rng.chance(self.config.wolf_breed_chance); turns.push(( id, AnimalTurn { drain: self.config.wolf_energy_drain, gain, max_energy: self.config.wolf_energy_max, position, breed_above: may_breed.then_some(self.config.wolf_breed_energy), death_threshold: self.config.animal_energy_death_threshold, }, )); } let breeders = self.live(Species::Wolf, census, turns); let cost = BirthCost { energy: self.config.wolf_breed_energy_cost, death_threshold: self.config.animal_energy_death_threshold, }; self.breed( Species::Wolf, census, breeders, self.shard.share(self.config.wolf_max_population), cost, self.config.wolf_newborn_energy, ); } /// Replaces the running script. Behaviours and tuning constants apply /// immediately; map size and calendar defaults apply on reset. pub fn set_script(&mut self, src: &str) -> Result<(), String> { let script = SimScript::load(src)?; self.vm = script.vm(); self.config = script.config.clone(); self.script = script; Ok(()) } pub fn restarted(&self, seed: u64) -> Self { let mut world = Self::new_shard(seed, self.script.clone(), self.shard); world.speed = self.speed; world.paused = self.paused; world.calendar = self.calendar.clone(); world.calendar.normalize(); world.tick = (world.calendar.ticks_per_day / world.config.starting_tick_fraction).max(1); world } pub fn regenerated_seed(&self) -> u64 { let mut seed = rand::random::(); if seed == self.seed { seed = seed.wrapping_add(1); } seed } pub fn seed(&self) -> u64 { self.seed } pub fn snapshot_json(&self) -> Result { let census = self.census(); serde_json::to_string(&WorldSnapshot { version: 1, seed: Some(self.seed), width: self.width, height: self.height, tiles: self.tiles.clone(), res: self.res.clone(), people: census.people, rabbits: census.rabbits.into_iter().map(|(_, a)| a).collect(), wolves: census.wolves.into_iter().map(|(_, a)| a).collect(), houses: self.houses.clone(), home: self.home, food: self.food, wood: self.wood, stone: self.stone, tools: self.tools, rng: self.rng.0, tick: self.tick, next_id: self.next_id, log: self.log.clone(), paused: self.paused, speed: self.speed, terrain_rev: self.terrain_rev, calendar: self.calendar.clone(), partnerships: self.observe_partnerships(), next_partnership_id: self.next_partnership_id, next_animal_id: Some(self.next_animal_id), shard: Some(self.shard), }) } pub fn script(&self) -> &str { self.script.source() } #[cfg(test)] pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result { Self::from_snapshot_with_script(snapshot, fallback_seed, default_script(), Shard::SINGLE) } /// Restores world state from a snapshot of `shard`; `script` (normally `config.rn` /// on disk) supplies the behaviours and tuning constants. pub fn from_snapshot_with_script( snapshot: &str, fallback_seed: u64, script: SimScript, shard: Shard, ) -> Result { let config = &script.config; 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 mut snapshot_value = snapshot_value; if snapshot_value.get("calendar").is_none() { let calendar = serde_json::to_value(&config.calendar).map_err(|e| e.to_string())?; snapshot_value .as_object_mut() .ok_or("world snapshot must be a JSON object")? .insert("calendar".into(), calendar); } let saved: WorldSnapshot = serde_json::from_value(snapshot_value).map_err(|e| e.to_string())?; if saved.version != 1 { return Err(format!( "unsupported world snapshot version {}", saved.version )); } if !(8..=256).contains(&saved.width) || !(8..=256).contains(&saved.height) || saved.tiles.len() != saved.width * saved.height || saved.res.len() != saved.width * saved.height { return Err("world snapshot has invalid terrain dimensions".into()); } let saved_shard = saved.shard.unwrap_or(Shard::SINGLE); if saved_shard != shard { return Err(format!( "world snapshot belongs to shard {} of {}, not {} of {}", saved_shard.index, saved_shard.count, shard.index, shard.count )); } let mut world = Self::empty( saved.seed.unwrap_or(fallback_seed), script, saved.tiles, saved.res, shard, ); world.width = saved.width; world.height = saved.height; world.next_partnership_id = saved.next_partnership_id; if let Some(next_animal_id) = saved.next_animal_id { world.next_animal_id = next_animal_id; } world.houses = saved.houses; world.home = saved.home; world.food = saved.food; world.wood = saved.wood; world.stone = saved.stone; world.tools = saved.tools; world.rng = Rng(saved.rng); world.tick = saved.tick; world.next_id = saved.next_id; world.log = saved.log; world.paused = saved.paused; world.speed = saved.speed; world.terrain_rev = saved.terrain_rev; world.calendar = saved.calendar; world.calendar.normalize(); let mut people = saved.people; if legacy_people { for (index, person) in people.iter_mut().enumerate() { let stage = if index < world.config.initial_young_adults { LifeStage::YoungAdult } else { LifeStage::Adult }; world.make_grown_up(person, stage); } } let mut partnerships = saved.partnerships; // Other shards are unknown until the cluster directory arrives; partnerships with // members elsewhere are checked by the daily pruning instead. let local: HashSet = people.iter().map(|person| person.id).collect(); let single = shard.count == 1; repair_partnerships( &mut people, &mut partnerships, |id| !single || local.contains(&id), single, ); for person in people { world.spawn_person_actor(person); } for rabbit in saved.rabbits { world.spawn_animal_actor(Species::Rabbit, rabbit); } for wolf in saved.wolves { world.spawn_animal_actor(Species::Wolf, wolf); } for partnership in partnerships { world.spawn_partnership_actor(partnership); } Ok(world) } fn rect(&self, t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> { let n: Vec = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect(); if n.len() != 4 { return None; } let c = |v: i64, max: usize| usize::try_from(v.max(0)).map_or(max - 1, |v| v.min(max - 1)); let (x0, x1) = (c(n[0].min(n[2]), self.width), c(n[0].max(n[2]), self.width)); let (y0, y1) = ( c(n[1].min(n[3]), self.height), c(n[1].max(n[3]), self.height), ); Some((x0, y0, x1, y1)) } /// Applies one whitespace-separated edit command; malformed commands are ignored. pub fn apply(&mut self, cmd: &str) { let t: Vec<&str> = cmd.split_whitespace().collect(); match t.first().copied() { Some("pause") => self.paused = arg(&t, 1) != Some(0), Some("calendar") => self.edit_calendar(&t), Some("stage") => self.edit_stage(&t), Some("speed") => { if let Some(n) = arg(&t, 1) { self.speed = clamp_to(n, 1, i64::from(self.config.max_simulation_speed)); } } Some("stock") => self.edit_stock(&t), Some("person") => self.edit_person(&t), Some("energy") => self.edit_energy(&t), Some("terrain") => { if let (Some((x0, y0, x1, y1)), Some(kind)) = (self.rect(&t, 1), arg(&t, 5)) && let Ok(kind) = u8::try_from(kind) && kind <= 6 { self.paint_terrain(x0, y0, x1, y1, kind); } } Some("res") => self.edit_resources(&t), Some("spawn") => self.edit_spawn(&t), Some("house") => self.edit_houses(&t), Some("remove") => self.edit_remove(&t), _ => {} } } fn edit_calendar(&mut self, t: &[&str]) { if let (Some(setting), Some(value)) = (t.get(1), arg(t, 2)) { let days: u32 = clamp_to(value, 1, 100); match *setting { "ticks_per_day" => self.calendar.ticks_per_day = clamp_to(value, 1, 1_000_000), "days_per_week" => self.calendar.days_per_week = days, "weeks_per_month" => self.calendar.weeks_per_month = days, "months_per_year" => self.calendar.months_per_year = days, _ => {} } } } fn edit_stage(&mut self, t: &[&str]) { let (Some(stage), Some(property)) = (t.get(1), t.get(2)) else { return; }; if *property == "fertile" { if let Some(value) = arg(t, 3) && let Some(range) = self.calendar.duration_mut(stage) { range.fertile = value != 0; } } else if let (Some(min_days), Some(max_days)) = (arg(t, 2), arg(t, 3)) { let min_days: u32 = clamp_to(min_days, 1, 1_000_000); let max_days: u32 = clamp_to(max_days, 1, 1_000_000); if min_days <= max_days && let Some(range) = self.calendar.duration_mut(stage) { range.min_days = min_days; range.max_days = max_days; } } } fn edit_stock(&mut self, t: &[&str]) { if let (Some(name), Some(v)) = (t.get(1), arg(t, 2)) { let v = clamp_to(v, 0, i64::from(self.config.max_stock_edit)); match *name { "food" => self.food = v, "wood" => self.wood = v, "stone" => self.stone = v, "tools" => self.tools = v, _ => {} } } } fn edit_person(&mut self, t: &[&str]) { let max_inventory = i64::from(self.config.max_person_inventory_edit); let max_hunger = self.config.max_hunger_edit; if let (Some(id), Some(field), Some(v)) = (arg(t, 1), t.get(2), arg(t, 3)) && let Ok(id) = u32::try_from(id) { let u = clamp_to(v, 0, max_inventory); let edit = match *field { "hunger" => PersonEdit::Hunger(small_f32(v.clamp(0, max_hunger))), "food" => PersonEdit::Food(u), "wood" => PersonEdit::Wood(u), "stone" => PersonEdit::Stone(u), _ => return, }; self.tell_person(id, PersonMsg::Edit(edit)); } } fn edit_energy(&mut self, t: &[&str]) { if let (Some((x0, y0, x1, y1)), Some(v)) = (self.rect(t, 1), arg(t, 5)) { let max = self.config.max_animal_energy_edit.trunc().max(1.0); let v = small_f32(v.clamp(1, 1_000_000)).min(max); let area = Area { x0, y0, x1, y1 }; for actor in self.rabbit_actors.values().chain(self.wolf_actors.values()) { actor.tell(AnimalMsg::SetEnergyWithin(area, v), None); } } } fn edit_resources(&mut self, t: &[&str]) { let (Some((x0, y0, x1, y1)), Some(kind), Some(amt)) = (self.rect(t, 1), arg(t, 5), arg(t, 6)) else { return; }; let Ok(kind) = u8::try_from(kind) else { return; }; if kind > 3 { return; } let amt = clamp_to(amt, 1, i64::from(self.config.max_resource_edit)); for y in y0..=y1 { for x in x0..=x1 { if !self.owns((x, y)) { continue; } let i = y * self.width + x; let free = self.walkable((x, y)) && (x, y) != self.home && !self.houses.contains(&(x, y)); if kind == 0 { self.res[i] = (0, 0); } else if free { self.res[i] = (kind, amt); } } } } fn edit_spawn(&mut self, t: &[&str]) { let (Some(what), Some((x0, y0, x1, y1))) = (t.get(1), self.rect(t, 2)) else { return; }; let mut left = self.config.manual_spawn_limit; for y in y0..=y1 { for x in x0..=x1 { if left == 0 || !self.walkable((x, y)) || !self.owns((x, y)) { continue; } left -= 1; let energy = self.config.initial_animal_energy; match *what { "person" => { self.spawn_person_at((x, y)); } "rabbit" => { self.spawn_animal(Species::Rabbit, (x, y), energy); } "wolf" => { self.spawn_animal(Species::Wolf, (x, y), energy); } _ => left += 1, } } } } fn edit_houses(&mut self, t: &[&str]) { let Some((x0, y0, x1, y1)) = self.rect(t, 1) else { return; }; for y in y0..=y1 { for x in x0..=x1 { if self.houses.len() < self.config.max_houses && self.owns((x, y)) && self.walkable((x, y)) && self.res[y * self.width + x].0 == 0 && (x, y) != self.home && !self.houses.contains(&(x, y)) { self.houses.push((x, y)); } } } } fn edit_remove(&mut self, t: &[&str]) { let (Some((x0, y0, x1, y1)), Some(what)) = (self.rect(t, 1), t.get(5)) else { return; }; let area = Area { x0, y0, x1, y1 }; let all = *what == "all"; if all || *what == "people" { let mut people = self.observe_people(); let removed: Vec = people .iter() .filter(|p| area.contains(p.position())) .map(|p| p.id) .collect(); if !removed.is_empty() { for &id in &removed { self.retire_person(id); } people.retain(|p| !removed.contains(&p.id)); self.prune_partnerships(&mut people); } } for (name, species) in [("rabbits", Species::Rabbit), ("wolves", Species::Wolf)] { if all || *what == name { for (id, animal) in self.observe_animals(species) { if area.contains(animal.position()) { self.retire_animal(species, id); } } } } if all || *what == "houses" { self.houses.retain(|h| !area.contains(*h)); } if all || *what == "res" { for y in y0..=y1 { for x in x0..=x1 { self.res[y * self.width + x] = (0, 0); } } } } fn paint_terrain(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, kind: u8) { let land = matches!(kind, SAND | GRASS | FOREST | HILLS); for y in y0..=y1 { for x in x0..=x1 { if !land && (x, y) == self.home { continue; } self.tiles[y * self.width + x] = kind; if !land { self.res[y * self.width + x] = (0, 0); self.houses.retain(|h| *h != (x, y)); } } } if !land { for species in [Species::Rabbit, Species::Wolf] { for (id, animal) in self.observe_animals(species) { if !self.walkable(animal.position()) { self.retire_animal(species, id); } } } for person in self.observe_people() { if !self.walkable(person.position()) { self.tell_person(person.id, PersonMsg::MoveTo(self.home)); } } } self.terrain_rev += 1; } pub fn terrain_json(&self) -> String { let tiles: String = self.tiles.iter().map(|t| (b'0' + t) as char).collect(); format!( "{{\"w\":{},\"h\":{},\"tiles\":\"{}\"}}", self.width, self.height, tiles ) } fn write_calendar_json(&self, s: &mut String) { let _ = write!( *s, "\"calendar\":{{\"ticks_per_day\":{},\"days_per_week\":{},\"weeks_per_month\":{},\"months_per_year\":{},", self.calendar.ticks_per_day, self.calendar.days_per_week, self.calendar.weeks_per_month, self.calendar.months_per_year ); for (index, (name, range)) in [ ("toddler", self.calendar.toddler), ("child", self.calendar.child), ("teen", self.calendar.teen), ("young_adult", self.calendar.young_adult), ("adult", self.calendar.adult), ("older_adult", self.calendar.older_adult), ("elderly", self.calendar.elderly), ] .into_iter() .enumerate() { if index > 0 { s.push(','); } let _ = write!( *s, "\"{name}\":{{\"min_days\":{},\"max_days\":{},\"fertile\":{}}}", range.min_days, range.max_days, range.fertile ); } s.push_str("},"); } fn write_entities_json(&self, s: &mut String) { let census = self.census(); s.push_str("\"houses\":["); let j: Vec = self .houses .iter() .map(|h| format!("[{},{}]", h.0, h.1)) .collect(); s.push_str(&j.join(",")); s.push_str("],\"people\":["); let j: Vec = census .people .iter() .map(|p| { format!( "[{},{},{},\"{}\",{:.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(); s.push_str(&j.join(",")); s.push_str("],\"rabbits\":["); let j: Vec = census .rabbits .iter() .map(|(_, a)| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) .collect(); s.push_str(&j.join(",")); s.push_str("],\"wolves\":["); let j: Vec = census .wolves .iter() .map(|(_, a)| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) .collect(); s.push_str(&j.join(",")); s.push_str("],\"res\":["); let mut first = true; for (i, r) in self.res.iter().enumerate() { if r.0 != 0 { if !first { s.push(','); } first = false; let _ = write!( *s, "[{},{},{},{}]", i % self.width, i / self.width, r.0, r.1 ); } } s.push_str("],\"log\":["); let j: Vec = self.log.iter().map(|l| format!("\"{l}\"")).collect(); s.push_str(&j.join(",")); } pub fn state_json(&self) -> String { let mut s = String::new(); let days_per_month = u64::from(self.calendar.days_per_week) * u64::from(self.calendar.weeks_per_month); let days_per_year = days_per_month * u64::from(self.calendar.months_per_year); let day = self.tick / self.calendar.ticks_per_day; let year = day / days_per_year + 1; let month = (day / days_per_month) % u64::from(self.calendar.months_per_year) + 1; let week = (day / u64::from(self.calendar.days_per_week)) % u64::from(self.calendar.weeks_per_month) + 1; let date_day = day % u64::from(self.calendar.days_per_week) + 1; let actor_stats = self.actor_system.stats(); let _ = write!( s, "{{\"paused\":{},\"speed\":{},\"seed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"year\":{},\"month\":{},\"week\":{},\"date_day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"limits\":{{\"speed\":{},\"stock\":{},\"person_inventory\":{},\"hunger\":{},\"animal_energy\":{},\"resource\":{},\"spawn\":{}}},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],", self.paused, self.speed, self.seed, self.terrain_rev, self.tick, day + 1, year, month, week, date_day, self.day_fraction() * 24.0, self.daylight(), self.config.max_simulation_speed, self.config.max_stock_edit, self.config.max_person_inventory_edit, self.config.max_hunger_edit, self.config.max_animal_energy_edit, self.config.max_resource_edit, self.config.manual_spawn_limit, self.food, self.wood, self.stone, self.tools, self.home.0, self.home.1 ); self.write_calendar_json(&mut s); self.write_entities_json(&mut s); let _ = write!( s, "],\"actor_stats\":{{\"actors_created\":{},\"actors_terminated\":{},\"actors_restarted\":{},\"messages_sent\":{},\"messages_failed\":{},\"system_messages_sent\":{},\"live_actors\":{}}}}}", actor_stats.actors_created, actor_stats.actors_terminated, actor_stats.actors_restarted, actor_stats.messages_sent, actor_stats.messages_failed, actor_stats.system_messages_sent, actor_stats.live_actors() ); s } #[cfg(feature = "console")] pub fn handle_picard_console_text(&self, request: &str) -> String { self.actor_system.console().handle_text(request) } } /// The interface the cluster uses to drive a shard. #[allow(dead_code, reason = "the cluster gateway and shard nodes use these")] impl World { pub fn shard(&self) -> Shard { self.shard } pub fn router(&self) -> &Arc { &self.router } /// Returns and resets what changed since the last report. pub fn take_report(&mut self) -> StepReport { std::mem::take(&mut self.report) } /// Every person and animal currently simulated by this shard. pub fn roster(&self) -> Vec { let people = self.person_actors.keys().map(|&id| EntityKey::person(id)); let rabbits = self .rabbit_actors .keys() .map(|&id| EntityKey::new(Kind::Rabbit, id)); let wolves = self .wolf_actors .keys() .map(|&id| EntityKey::new(Kind::Wolf, id)); people.chain(rabbits).chain(wolves).collect() } } impl Drop for World { fn drop(&mut self) { // Shutdown starts eagerly; Drop must not block waiting for completion. drop(self.actor_system.shutdown()); } } #[cfg(test)] mod tests { use super::actors::{Activity, AnimalTurn, Goods, PersonEdit, PersonMsg, ReplyBody}; use super::{ Animal, DEFAULT_SCRIPT, EntityKey, FORWARDING_TICKS, H, LifeStage, Router, Shard, SimScript, W, World, cheb, default_config, }; use crate::world::router::RemoteSender; use picard::remoting::{ActorAddress, RemoteRequest}; use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, Instant}; /// Delivers remote messages by calling the receiving shard's router directly. #[derive(Default)] struct Loopback { routers: Mutex>>, } impl RemoteSender for Loopback { fn send(&self, shard: usize, path: &str, payload: serde_json::Value) { let router = self.routers.lock().unwrap()[shard].upgrade().unwrap(); let destination = ActorAddress::local(router.system_id(), path); let response = router.handle("loopback", RemoteRequest::new(destination, None, payload)); assert!(response.accepted, "{path} rejected: {:?}", response.error); } fn flush(&self) {} } fn connected_shards(count: usize) -> Vec { let loopback = Arc::new(Loopback::default()); let shards: Vec = (0..count) .map(|index| { let shard = Shard::new(index, count).unwrap(); World::new_shard(42, SimScript::load(DEFAULT_SCRIPT).unwrap(), shard) }) .collect(); for world in &shards { loopback .routers .lock() .unwrap() .push(Arc::downgrade(world.router())); world.router().set_remote(loopback.clone()); } shards } /// Does what the gateway does after every step: publishes where entities went. fn publish_directory(shards: &mut [World]) { let mut changes = Vec::new(); for (index, world) in shards.iter_mut().enumerate() { let report = world.take_report(); changes.extend(report.moved.into_iter().map(|(key, to)| (key, Some(to)))); changes.extend(report.arrived.into_iter().map(|key| (key, Some(index)))); changes.extend(report.left.into_iter().map(|key| (key, None))); } for world in shards { world.router().apply_directory(&changes); } } fn wait_for_person(world: &World, id: u32, done: impl Fn(&super::Person) -> bool) { let deadline = Instant::now() + Duration::from_secs(5); loop { let people = world.observe_people(); if people.iter().any(|person| person.id == id && done(person)) { return; } assert!( Instant::now() < deadline, "person #{id} never got the message" ); std::thread::sleep(Duration::from_millis(10)); } } /// Replaces the value of `pub const {name}` in `source`, whatever its current value is. fn with_constant(source: &str, name: &str, value: &str) -> String { let prefix = format!("pub const {name} = "); assert!( source.lines().any(|line| line.starts_with(&prefix)), "config.rn has no constant {name}" ); source .lines() .map(|line| { if line.starts_with(&prefix) { format!("{prefix}{value};") } else { line.to_string() } }) .collect::>() .join("\n") } /// The default script at the size of the original single village, for long-running tests. fn village_script() -> SimScript { let source = [ ("WIDTH", "96"), ("HEIGHT", "64"), ("INITIAL_POPULATION", "90"), ("INITIAL_YOUNG_ADULTS", "30"), ("INITIAL_RABBITS", "600"), ("INITIAL_WOLVES", "40"), ("INITIAL_FOOD", "600"), ("POPULATION_CAPACITY", "12"), ("MAX_HOUSES", "10"), ("RABBIT_SPAWN_CAP", "15"), ("WOLF_SPAWN_CAP", "2"), ("WOLF_SPAWN_RABBIT_THRESHOLD", "40"), ("RABBIT_MAX_POPULATION", "120"), ("WOLF_MAX_POPULATION", "10"), ] .into_iter() .fold(DEFAULT_SCRIPT.to_string(), |source, (name, value)| { with_constant(&source, name, value) }); SimScript::load(&source).unwrap() } fn is_fertile(world: &World, stage: LifeStage) -> bool { world .calendar .duration(stage.key()) .is_some_and(|duration| duration.fertile) } fn simulation_state(world: &World) -> serde_json::Value { let mut state: serde_json::Value = serde_json::from_str(&world.state_json()).unwrap(); state.as_object_mut().unwrap().remove("actor_stats"); state } #[test] fn shards_split_resources_and_entities_without_overlap() { let single = World::new(42); let shards = connected_shards(3); let width = single.width; for (index, res) in single.res.iter().enumerate() { let owners = shards.iter().filter(|w| w.res[index].0 != 0).count(); assert!(owners <= 1, "tile {index} has resources on several shards"); let position = (index % width, index / width); if res.0 != 0 && position != single.home { let expected = &shards[Shard::new(0, 3).unwrap().owner(position.0, width)]; assert!(position == expected.home || expected.res[index] == *res); } } let mut ids: Vec = shards.iter().flat_map(World::roster).collect(); let total = ids.len(); ids.sort_unstable(); ids.dedup(); assert_eq!(ids.len(), total, "entity ids must be unique across shards"); let people: usize = shards.iter().map(|w| w.person_actors.len()).sum(); assert_eq!(people, single.person_actors.len()); for world in &shards { assert!(world.owns(world.home)); } } #[test] fn people_cross_shards_with_their_inventory_and_stay_reachable() { let mut shards = connected_shards(2); publish_directory(&mut shards); let id = *shards[0].person_actors.keys().next().unwrap(); let destination = shards[1].home; shards[0].tell_person(id, PersonMsg::Edit(PersonEdit::Wood(7))); shards[0].tell_person(id, PersonMsg::MoveTo(destination)); shards[0].step(); assert!(!shards[0].person_actors.contains_key(&id)); let report = shards[0].take_report(); assert_eq!(report.moved, vec![(EntityKey::person(id), 1)]); shards[1].receive_arrivals(); assert!(shards[1].person_actors.contains_key(&id)); let arrived = shards[1] .observe_people() .into_iter() .find(|person| person.id == id) .unwrap(); assert_eq!(arrived.wood, 7); assert!(shards[1].owns(arrived.position())); // The old actor forwards messages sent to the person on the shard they left. shards[0].tell_person(id, PersonMsg::Edit(PersonEdit::Stone(3))); wait_for_person(&shards[1], id, |person| person.stone == 3); // Once it stops, the router forwards them itself until the directory catches up. shards[0].tick += FORWARDING_TICKS; shards[0].retire_departures(); assert!(shards[0].departed.is_empty()); shards[0].tell_person(id, PersonMsg::Edit(PersonEdit::Stone(4))); wait_for_person(&shards[1], id, |person| person.stone == 4); publish_directory(&mut shards); shards[0].tell_person(id, PersonMsg::Edit(PersonEdit::Stone(5))); wait_for_person(&shards[1], id, |person| person.stone == 5); // Walking back resumes life on the first shard. let back = shards[0].home; shards[1].tell_person(id, PersonMsg::MoveTo(back)); shards[1].step(); shards[0].receive_arrivals(); assert!(shards[0].person_actors.contains_key(&id)); assert!(!shards[1].person_actors.contains_key(&id)); publish_directory(&mut shards); shards[1].tell_person(id, PersonMsg::Edit(PersonEdit::Stone(6))); wait_for_person(&shards[0], id, |person| person.stone == 6); } #[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; } let id = world.spawn_person_at(world.home).id; assert_eq!( world.observe_people().last().unwrap().stage, LifeStage::Toddler ); for expected in [ LifeStage::Child, LifeStage::Teen, LifeStage::YoungAdult, LifeStage::Adult, LifeStage::OlderAdult, LifeStage::Elderly, LifeStage::Dead, ] { let people = world.advance_people(); let person = people.iter().find(|person| person.id == id).unwrap(); assert_eq!(person.stage, expected); } let people = world.observe_people(); assert!(people.iter().find(|person| person.id == id).unwrap().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.food = 1000; let mut census = world.census(); let eligible = census .people .iter() .filter(|person| person.stage.can_partner()) .count(); world.find_partners(&mut census.people); let partnerships = eligible / 2; assert!(partnerships > 0); assert_eq!(world.partnership_actors.len(), partnerships); assert_eq!(world.observe_partnerships().len(), partnerships); assert_eq!( world .observe_people() .iter() .filter(|person| person.partner_id.is_some()) .count(), partnerships * 2 ); let initial_population = census.people.len(); let births = 3; world.config.population_capacity = initial_population + births; while census.people.len() < world.population_capacity() { world.tick += 1; world.have_children(1, &mut census); } let people = world.observe_people(); assert_eq!(people.len(), world.population_capacity()); assert_eq!(people.len() - initial_population, births); let toddler = people .iter() .find(|person| person.stage == LifeStage::Toddler) .expect("fertile partner actors should eventually have a child"); assert_eq!(toddler.age_ticks, 0); assert_eq!((toddler.x, toddler.y), world.home); } #[test] fn each_fertile_partnership_can_have_a_weekly_birth() { let mut world = World::new(42); world.calendar.ticks_per_day = 1; let mut census = world.census(); world.find_partners(&mut census.people); let partnerships = world.observe_partnerships(); let fertile_partnerships = partnerships .iter() .filter(|partnership| { [partnership.first_person_id, partnership.second_person_id] .iter() .all(|id| { let person = census.people.iter().find(|person| person.id == *id); person.is_some_and(|person| is_fertile(&world, person.stage)) }) }) .count(); assert!(fertile_partnerships > 0); let initial_population = census.people.len(); world.config.population_capacity = initial_population + partnerships.len(); world.food = world.config.birth_food_threshold + world.config.birth_food_cost * u32::try_from(fertile_partnerships).unwrap(); world.tick += 7; world.have_children(7, &mut census); let people = world.observe_people(); assert_eq!(people.len(), initial_population + fertile_partnerships); assert_eq!( people .iter() .filter(|person| person.stage == LifeStage::Toddler) .count(), fertile_partnerships ); } #[test] fn people_can_pathfind_back_home_around_blocked_terrain() { let mut world = World::new_with_script(42, village_script()); 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)); } #[test] fn village_survives_until_its_first_weekly_birth_check() { let mut world = World::new_with_script(42, village_script()); let first_week_tick = world.calendar.ticks_per_day * u64::from(world.calendar.days_per_week); 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.person_actors.len(), world.food)); } } let initial_population = world.config.initial_population; let minimum_population = if initial_population < world.population_capacity() { initial_population + 1 } else { initial_population }; assert!( world.person_actors.len() >= minimum_population, "village failed to replace its initial population: daily={daily:?}, people={:?}, events={:?}", world .observe_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); 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(); let config = default_config(); let people = restored.observe_people(); assert_eq!(people.len(), config.initial_population); assert_eq!(people[0].stage, LifeStage::YoungAdult); assert!( people .iter() .skip(config.initial_young_adults) .all(|person| person.stage == LifeStage::Adult) ); } #[test] fn partnership_and_life_stage_are_in_persisted_actor_state() { let mut world = World::new(42); let mut census = world.census(); world.find_partners(&mut census.people); let restored = World::from_snapshot(&world.snapshot_json().unwrap(), 42).unwrap(); assert!(!world.partnership_actors.is_empty()); assert_eq!( restored.observe_partnerships().len(), world.partnership_actors.len() ); assert_eq!(simulation_state(&restored), simulation_state(&world)); assert_eq!( serde_json::from_str::(&restored.state_json()).unwrap()["people"][0] [8], "young_adult" ); } #[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)); let id = world.spawn_person_at(position).id; let mut census = world.census(); world.tick_people(&mut census); let child = census.people.iter().find(|person| person.id == id).unwrap(); assert!(cheb((child.x, child.y), home) < cheb(position, home)); assert_eq!(child.action, "growing up"); let observed = world.observe_people(); let observed = observed.iter().find(|person| person.id == id).unwrap(); assert_eq!((observed.x, observed.y), (child.x, child.y)); } #[test] fn a_person_eats_available_food_before_starving() { let mut world = World::new(42); world.food = 1; let id = world.observe_people()[0].id; world.apply(&format!("person {id} hunger 99")); let person = world.observe_people()[0].clone(); let fed = world.meet_needs(&person).unwrap(); assert!(!fed.dead); assert!(fed.hunger < 100.0); assert_eq!(world.food, 0); } #[test] fn snapshot_restores_state_and_rng() { let mut original = World::new(42); for _ in 0..10 { original.step(); } original.paused = true; original.speed = 4; let snapshot = original.snapshot_json().unwrap(); let mut restored = World::from_snapshot(&snapshot, 42).unwrap(); assert_eq!(simulation_state(&original), simulation_state(&restored)); original.step(); restored.step(); assert_eq!(simulation_state(&original), simulation_state(&restored)); } #[test] fn reset_keeps_seed_and_settings_with_initial_population() { let mut original = World::new(42); original.apply("speed 4"); original.paused = true; original.set_script(DEFAULT_SCRIPT).unwrap(); original.step(); let reset = original.restarted(original.seed()); assert_eq!(reset.seed(), 42); assert_eq!(reset.speed, 4); assert!(reset.paused); assert_eq!( reset.person_actors.len(), default_config().initial_population ); assert_eq!( reset.tick, default_config().calendar.ticks_per_day / default_config().starting_tick_fraction ); } #[test] fn regenerated_seed_differs_from_current_seed() { let world = World::new(42); assert_ne!(world.regenerated_seed(), world.seed()); } #[test] fn entity_actor_messages_round_trip_through_serde() { let message = PersonMsg::Perform( 7, Activity { action: "foraging".into(), position: (3, 4), gathered: Goods { food: 2, wood: 0, stone: 1, }, }, ); let serialized = serde_json::to_string(&message).unwrap(); let PersonMsg::Perform(request, activity) = serde_json::from_str(&serialized).unwrap() else { panic!("serialized person message changed variants"); }; assert_eq!((request, activity.position), (7, (3, 4))); assert_eq!(activity.gathered.stone, 1); let reply = ReplyBody::Lived( 9, Animal { x: 1, y: 2, energy: 30.0, dead: false, }, true, ); let serialized = serde_json::to_string(&reply).unwrap(); let ReplyBody::Lived(id, animal, wants_to_breed) = serde_json::from_str(&serialized).unwrap() else { panic!("serialized animal reply changed variants"); }; assert_eq!((id, animal.position(), wants_to_breed), (9, (1, 2), true)); let turn: AnimalTurn = serde_json::from_str( r#"{"drain":1.0,"gain":null,"max_energy":2.0,"position":[0,0],"breed_above":null,"death_threshold":0.0}"#, ) .unwrap(); assert!(turn.gain.is_none()); } #[test] fn picard_actors_own_living_entity_state() { let mut world = World::new(42); let person_id = world.observe_people()[0].id; world.apply(&format!("person {person_id} hunger 73")); assert!((world.observe_people()[0].hunger - 73.0).abs() < f32::EPSILON); let entities = world.person_actors.len() + world.rabbit_actors.len() + world.wolf_actors.len() + world.partnership_actors.len(); let census = world.census(); assert_eq!( census.people.len() + census.rabbits.len() + census.wolves.len(), entities ); let state: serde_json::Value = serde_json::from_str(&world.state_json()).unwrap(); assert!(state["actor_stats"]["live_actors"].as_u64().unwrap() > entities as u64); assert!(state["actor_stats"]["messages_sent"].as_u64().unwrap() > 0); } #[test] fn dead_entities_stop_their_actors_and_dissolve_partnerships() { let mut world = World::new(42); let mut census = world.census(); world.find_partners(&mut census.people); let partnership = world.observe_partnerships()[0].clone(); let partner = census .people .iter() .find(|person| person.id == partnership.first_person_id) .unwrap() .clone(); let survivor_id = partnership.second_person_id; world.apply(&format!( "remove {x} {y} {x} {y} people", x = partner.x, y = partner.y )); assert!(!world.person_actors.contains_key(&partner.id)); assert!(!world.partnership_actors.contains_key(&partnership.id)); if let Some(survivor) = world .observe_people() .iter() .find(|person| person.id == survivor_id) { assert_eq!(survivor.partner_id, None); } let rabbit = world.observe_animals(super::Species::Rabbit)[0].clone(); world.apply(&format!( "remove {x} {y} {x} {y} rabbits", x = rabbit.1.x, y = rabbit.1.y )); assert!(!world.rabbit_actors.contains_key(&rabbit.0)); } #[test] fn calendar_and_life_stage_ranges_persist_in_snapshots() { let mut world = World::new(42); world.apply("calendar ticks_per_day 240"); world.apply("calendar days_per_week 5"); world.apply("stage toddler 3 9"); let snapshot = world.snapshot_json().unwrap(); let restored = World::from_snapshot(&snapshot, 42).unwrap(); assert_eq!(restored.calendar.ticks_per_day, 240); assert_eq!(restored.calendar.days_per_week, 5); assert_eq!(restored.calendar.toddler.min_days, 3); assert_eq!(restored.calendar.toddler.max_days, 9); assert_eq!(simulation_state(&world), simulation_state(&restored)); } #[test] fn snapshots_without_calendar_use_default_settings() { let world = World::new(42); let mut snapshot: serde_json::Value = serde_json::from_str(&world.snapshot_json().unwrap()).unwrap(); snapshot.as_object_mut().unwrap().remove("calendar"); let restored = World::from_snapshot(&snapshot.to_string(), 42).unwrap(); assert_eq!( restored.calendar.ticks_per_day, default_config().calendar.ticks_per_day ); assert_eq!(restored.calendar.days_per_week, 7); } #[test] fn rune_config_controls_initial_world_and_survives_snapshot_restore() { let source = [ ("WIDTH", "48"), ("HEIGHT", "32"), ("INITIAL_POPULATION", "6"), ("INITIAL_YOUNG_ADULTS", "2"), ("INITIAL_FOOD", "125"), ("TICKS_PER_DAY", "240"), ] .into_iter() .fold(DEFAULT_SCRIPT.to_string(), |source, (name, value)| { with_constant(&source, name, value) }); let script = SimScript::load(&source).unwrap(); let world = World::new_with_script(42, script.clone()); assert_eq!((world.width, world.height), (48, 32)); assert_eq!(world.person_actors.len(), 6); assert_eq!(world.food, 125); assert_eq!(world.calendar.ticks_per_day, 240); assert!(world.terrain_json().contains("\"w\":48,\"h\":32")); let state: serde_json::Value = serde_json::from_str(&world.state_json()).unwrap(); assert_eq!( state["limits"]["speed"], default_config().max_simulation_speed ); let restored = World::from_snapshot_with_script( &world.snapshot_json().unwrap(), 42, script, Shard::SINGLE, ) .unwrap(); assert_eq!((restored.width, restored.height), (48, 32)); assert_eq!(simulation_state(&restored), simulation_state(&world)); } #[test] fn restored_snapshots_use_the_provided_script() { let mut world = World::new(42); let edited = with_constant(DEFAULT_SCRIPT, "MAX_SIMULATION_SPEED", "3"); world.set_script(&edited).unwrap(); assert_eq!(world.config.max_simulation_speed, 3); assert_eq!(world.script(), edited); let restored = World::from_snapshot(&world.snapshot_json().unwrap(), 42).unwrap(); assert_eq!(restored.script(), DEFAULT_SCRIPT); assert_eq!( restored.config.max_simulation_speed, default_config().max_simulation_speed ); } #[test] fn behaviours_read_script_constants() { let mut world = World::new(42); let all_stay = with_constant(DEFAULT_SCRIPT, "RABBIT_BEHAVIOR_WANDER_CHANCE", "0.0"); world.set_script(&all_stay).unwrap(); let c = super::Ctx { roll: 0.25, ..Default::default() }; assert_eq!(world.decide("rabbit", c.clone()), "stay"); let all_wander = with_constant(DEFAULT_SCRIPT, "RABBIT_BEHAVIOR_WANDER_CHANCE", "1.0"); world.set_script(&all_wander).unwrap(); assert_eq!(world.decide("rabbit", c), "wander"); } #[test] fn invalid_rune_configuration_is_rejected() { let source = with_constant( &with_constant(DEFAULT_SCRIPT, "INITIAL_POPULATION", "3"), "INITIAL_YOUNG_ADULTS", "4", ); assert!( SimScript::load(&source) .err() .unwrap() .contains("initial_young_adults cannot exceed initial_population") ); let missing = DEFAULT_SCRIPT .lines() .filter(|line| !line.starts_with("pub const WIDTH = ")) .collect::>() .join("\n"); assert!(SimScript::load(&missing).err().unwrap().contains("WIDTH")); let no_behaviour = DEFAULT_SCRIPT.replace("pub fn wolf(c)", "pub fn wolves(c)"); assert!(SimScript::load(&no_behaviour).is_err()); } }