sim/src/world.rs

4699 lines
164 KiB
Rust
Raw Normal View History

mod actors;
mod economy;
mod router;
mod work;
use actors::{
Activity, AnimalId, AnimalMsg, AnimalTurn, Area, BirthCost, Goods, Mailbox, Needs,
PartnershipMsg, PersonEdit, PersonMsg, ReplyBody, RequestId,
};
pub use economy::Quote;
use economy::{Cart, CartMsg, Job, Market, MarketMsg, Workplace, WorkplaceMsg};
use picard::actors::{ActorRef, ActorSystem, Sender, Tell};
use router::{Arrival, LocalActor};
pub use router::{EntityKey, Kind, RemoteSender, 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");
/// A random seed for a new world that differs from `current`.
pub fn regenerated_seed(current: u64) -> u64 {
let mut seed = rand::random::<u64>();
if seed == current {
seed = seed.wrapping_add(1);
}
seed
}
#[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,
}
/// Base price and the stock a market aims to hold of one good.
#[derive(Clone, Copy, Debug, rune::FromValue)]
pub struct GoodSettings {
pub price: u32,
pub target: u32,
}
/// How many workplaces of one trade each shard founds, their positions and the daily wage.
#[derive(Clone, Copy, Debug, rune::FromValue)]
pub struct TradeSettings {
pub count: usize,
pub workers: usize,
pub wage: u32,
}
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,
work_start: f32,
work_end: f32,
carry_capacity: u32,
cart_capacity: u32,
cart_durability: u32,
cart_wood_cost: u32,
initial_person_coins: u32,
initial_workplace_coins: u32,
initial_market_coins: u32,
quit_after_unpaid_days: u32,
household_food_target: u32,
household_coin_reserve: u32,
cart_purchase_coins: u32,
work_radius: usize,
field_radius: usize,
crop_ripe: u16,
crop_yield: u32,
craft_budget: u32,
merchant_escrow: u32,
haggle_rounds: u32,
price_food: GoodSettings,
price_wood: GoodSettings,
price_stone: GoodSettings,
price_tools: GoodSettings,
price_carts: GoodSettings,
trade_farm: TradeSettings,
trade_woodcutter: TradeSettings,
trade_quarry: TradeSettings,
trade_hunter: TradeSettings,
trade_toolmaker: TradeSettings,
trade_cartwright: TradeSettings,
trade_merchant: TradeSettings,
}
impl SimulationConfig {
fn validate(&self) -> Result<(), String> {
self.validate_limits()?;
self.validate_calendar()?;
self.validate_chances()?;
self.validate_world()?;
self.validate_amounts()?;
self.validate_economy()
}
fn validate_economy(&self) -> Result<(), String> {
if !(0.0..1.0).contains(&self.work_start)
|| !(self.work_start..=1.0).contains(&self.work_end)
|| self.work_start >= self.work_end
{
return Err("work_start and work_end must satisfy 0 <= start < end <= 1".into());
}
if self.carry_capacity == 0
|| self.carry_capacity > 10_000
|| self.cart_capacity > 10_000
|| self.cart_durability == 0
|| self.cart_wood_cost == 0
|| self.crop_ripe == 0
|| !(1..=100).contains(&self.haggle_rounds)
|| self.work_radius > 64
|| self.field_radius > 8
{
return Err("economy capacities, costs and radii are outside their ranges".into());
}
for (name, value) in [
("initial_person_coins", self.initial_person_coins),
("initial_workplace_coins", self.initial_workplace_coins),
("initial_market_coins", self.initial_market_coins),
("household_food_target", self.household_food_target),
("household_coin_reserve", self.household_coin_reserve),
("cart_purchase_coins", self.cart_purchase_coins),
("crop_yield", self.crop_yield),
("craft_budget", self.craft_budget),
("merchant_escrow", self.merchant_escrow),
] {
if value > 1_000_000 {
return Err(format!("{name} must be at most 1000000"));
}
}
for (name, good) in [
("price_food", self.price_food),
("price_wood", self.price_wood),
("price_stone", self.price_stone),
("price_tools", self.price_tools),
("price_carts", self.price_carts),
] {
if !(1..=100_000).contains(&good.price) || !(1..=100_000).contains(&good.target) {
return Err(format!(
"{name} needs a price and target between 1 and 100000"
));
}
}
for (name, trade) in self.trades() {
if trade.count > 16 || trade.workers > 100 || trade.wage > 100_000 {
return Err(format!(
"trade_{name} allows at most 16 sites, 100 workers and a wage of 100000"
));
}
}
if self.trade_merchant.count > 1 {
return Err("trade_merchant.count is the number of markets per shard: 0 or 1".into());
}
Ok(())
}
fn trades(&self) -> [(&'static str, TradeSettings); 7] {
[
("farm", self.trade_farm),
("woodcutter", self.trade_woodcutter),
("quarry", self.trade_quarry),
("hunter", self.trade_hunter),
("toolmaker", self.trade_toolmaker),
("cartwright", self.trade_cartwright),
("merchant", self.trade_merchant),
]
}
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",
"work_start",
"work_end",
"carry_capacity",
"cart_capacity",
"cart_durability",
"cart_wood_cost",
"initial_person_coins",
"initial_workplace_coins",
"initial_market_coins",
"quit_after_unpaid_days",
"household_food_target",
"household_coin_reserve",
"cart_purchase_coins",
"work_radius",
"field_radius",
"crop_ripe",
"crop_yield",
"craft_budget",
"merchant_escrow",
"haggle_rounds",
"price_food",
"price_wood",
"price_stone",
"price_tools",
"price_carts",
"trade_farm",
"trade_woodcutter",
"trade_quarry",
"trade_hunter",
"trade_toolmaker",
"trade_cartwright",
"trade_merchant",
];
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::<Vec<_>>()
.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 {
#[cfg_attr(not(test), expect(dead_code, reason = "tests read the source back"))]
source: String,
config: SimulationConfig,
runtime: Arc<RuntimeContext>,
unit: Arc<Unit>,
}
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<Self, String> {
let mut module = Module::new();
module.ty::<Ctx>().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::<SimulationConfig>(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::<String>(v).map_err(|_| format!("{f}() must return a string"))?;
}
Ok(Self {
source: src.to_string(),
config,
runtime,
unit,
})
}
#[cfg(test)]
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<SimScript> = 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<Self> {
match self {
Self::Toddler => Some(Self::Child),
Self::Child => Some(Self::Teen),
Self::Teen => Some(Self::YoungAdult),
Self::YoungAdult => Some(Self::Adult),
Self::Adult => Some(Self::OlderAdult),
Self::OlderAdult => Some(Self::Elderly),
Self::Elderly => Some(Self::Dead),
Self::Dead => None,
}
}
fn key(self) -> &'static str {
match self {
Self::Toddler => "toddler",
Self::Child => "child",
Self::Teen => "teen",
Self::YoungAdult => "young_adult",
Self::Adult => "adult",
Self::OlderAdult => "older_adult",
Self::Elderly => "elderly",
Self::Dead => "dead",
}
}
fn can_partner(self) -> bool {
matches!(
self,
Self::YoungAdult | Self::Adult | Self::OlderAdult | Self::Elderly
)
}
}
const WATER_DEEP: u8 = 0;
const WATER: u8 = 1;
const SAND: u8 = 2;
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;
/// A sown field tile; its amount is how far the crop has grown.
const CROP: u8 = 4;
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<i64> {
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<T: TryFrom<i64>>(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,
#[rune(get)]
employed: bool,
/// Whether it is working hours.
#[rune(get)]
work_time: bool,
/// Whether the person's shift for today is over.
#[rune(get)]
shift_done: bool,
/// Whether the person is in the middle of a shift and should finish it.
#[rune(get)]
on_shift: bool,
/// Whether the person is a merchant on a trading trip.
#[rune(get)]
on_trip: bool,
#[rune(get)]
coins: i64,
}
#[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<u32>,
/// State of the person's own random stream, used for their life-stage durations.
#[serde(default)]
rng: u64,
#[serde(default)]
tools: u32,
#[serde(default)]
coins: u32,
/// The village the person lives in; `None` in snapshots from before people had homes.
#[serde(default)]
home: Option<(usize, usize)>,
#[serde(default)]
job: Option<Job>,
/// The cart the person owns.
#[serde(default)]
cart: Option<u64>,
/// Shifts in a row the employer could not pay in full.
#[serde(default)]
unpaid_days: u32,
}
fn default_stage_duration_days() -> u32 {
1
}
impl Person {
fn carry(&self) -> u32 {
self.food + self.wood + self.stone + self.tools
}
/// Everything the person carries, their own goods and their employer's.
fn load(&self) -> u32 {
self.carry() + self.job.as_ref().map_or(0, |job| job.goods.total())
}
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<EntityKey>,
/// Entities that died or were removed.
pub left: Vec<EntityKey>,
}
/// Reusable A* buffers; `visited` marks which entries belong to the current search.
#[derive(Default)]
struct PathScratch {
search: u32,
visited: Vec<u32>,
costs: Vec<usize>,
previous: Vec<usize>,
frontier: BinaryHeap<Reverse<(usize, usize)>>,
}
/// 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<Person>,
rabbits: Vec<(AnimalId, Animal)>,
wolves: Vec<(AnimalId, Animal)>,
carts: Vec<Cart>,
}
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<u8>,
res: Vec<(u8, u16)>,
person_actors: BTreeMap<u32, ActorRef<PersonMsg>>,
rabbit_actors: BTreeMap<AnimalId, ActorRef<AnimalMsg>>,
wolf_actors: BTreeMap<AnimalId, ActorRef<AnimalMsg>>,
partnership_actors: BTreeMap<u32, ActorRef<PartnershipMsg>>,
workplace_actors: BTreeMap<u64, ActorRef<WorkplaceMsg>>,
market_actors: BTreeMap<u64, ActorRef<MarketMsg>>,
cart_actors: BTreeMap<u64, ActorRef<CartMsg>>,
/// Field tiles of the farms on this shard.
fields: HashSet<usize>,
/// The latest prices of every market in the cluster.
board: Vec<Quote>,
/// Ids of workplaces, markets and carts share the animal id sequence.
next_animal_id: AnimalId,
next_partnership_id: u32,
mailbox: Mailbox,
actor_system: ActorSystem,
shard: Shard,
router: Arc<Router>,
departed: Vec<Departure>,
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<String>,
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<u64>,
#[serde(default = "default_width")]
width: usize,
#[serde(default = "default_height")]
height: usize,
tiles: Vec<u8>,
res: Vec<(u8, u16)>,
people: Vec<Person>,
rabbits: Vec<Animal>,
wolves: Vec<Animal>,
houses: Vec<(usize, usize)>,
home: (usize, usize),
food: u32,
wood: u32,
stone: u32,
tools: u32,
rng: u64,
tick: u64,
next_id: u32,
log: Vec<String>,
paused: bool,
speed: u32,
terrain_rev: u32,
#[serde(default)]
calendar: CalendarSettings,
#[serde(default)]
partnerships: Vec<Partnership>,
#[serde(default = "default_next_partnership_id")]
next_partnership_id: u32,
#[serde(default)]
next_animal_id: Option<AnimalId>,
#[serde(default)]
shard: Option<Shard>,
#[serde(default)]
workplaces: Vec<Workplace>,
#[serde(default)]
markets: Vec<Market>,
#[serde(default)]
carts: Vec<Cart>,
}
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<Partnership>,
alive: impl Fn(u32) -> bool,
clear_orphans: bool,
) -> Vec<Partnership> {
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<K: Ord, T>(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<u8> {
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);
person.coins = self.config.initial_person_coins;
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<u8>,
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(),
workplace_actors: BTreeMap::new(),
market_actors: BTreeMap::new(),
cart_actors: BTreeMap::new(),
fields: HashSet::new(),
board: Vec::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.found_economy();
let people = w.observe_people();
w.employ(&people);
w
}
/// The columns of the map this shard simulates.
fn columns(&self) -> Range<usize> {
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<AnimalId, ActorRef<AnimalMsg>> {
match species {
Species::Rabbit => &self.rabbit_actors,
Species::Wolf => &self.wolf_actors,
}
}
fn animal_actors_mut(
&mut self,
species: Species,
) -> &mut BTreeMap<AnimalId, ActorRef<AnimalMsg>> {
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)
}
Arrival::Cart(id, actor) => {
self.cart_actors.insert(id, actor);
EntityKey::new(Kind::Cart, 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)));
}
}
}
for cart in &census.carts {
if !self.owns(cart.position())
&& let Some(actor) = self.cart_actors.remove(&cart.id)
{
let shard = self.shard.owner(cart.x, self.width);
actor.tell(CartMsg::Depart(request, shard), self.mailbox.reply_to());
leaving.push((cart.key(), shard, LocalActor::Cart(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<K, M: picard::LocalMessage>(
&self,
actors: &BTreeMap<K, ActorRef<M>>,
message: impl Fn(RequestId) -> M,
) -> Vec<ReplyBody> {
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<Person> {
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<Partnership> {
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),
carts: self.observe_carts(),
}
}
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<M: picard::LocalMessage>(
&self,
actor: &ActorRef<M>,
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,
tools: 0,
coins: 0,
home: Some(self.home),
job: None,
cart: None,
unpaid_days: 0,
}
}
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<usize> {
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 new_day = self.tick.is_multiple_of(self.calendar.ticks_per_day);
if new_day {
self.grow_crops();
}
let mut census = Census {
people: self.advance_people(),
rabbits: self.observe_animals(Species::Rabbit),
wolves: self.observe_animals(Species::Wolf),
carts: self.observe_carts(),
};
if self.tick.is_multiple_of(self.config.entity_update_interval) {
self.tick_people(&mut census);
self.tick_carts(&mut census);
self.tick_rabbits(&mut census);
} else {
self.tick_wolves(&mut census);
}
if self.retire_dead(&mut census) || (self.shard.count > 1 && new_day) {
self.prune_partnerships(&mut census.people);
}
self.population(&mut census);
if new_day {
self.employ(&census.people);
self.refresh_board();
}
self.transfer_out(&census);
}
/// Ages every person by one tick and returns them, logging stage changes and deaths.
fn advance_people(&mut self) -> Vec<Person> {
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<u32> = census
.people
.iter()
.filter(|person| person.dead)
.map(|person| person.id)
.collect();
for person in census.people.iter().filter(|person| person.dead) {
self.leave_job(person);
}
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<AnimalId> = 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<Option<u32>> = people.iter().map(|p| p.partner_id).collect();
let local: HashSet<u32> = 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 || self.fields.contains(&i) {
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::<String>(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<Person> {
let actor = self.person_actors.get(&person.id)?;
let at_home = self.at_home(person);
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 up to `room` of `kind` at `target` when standing on it, otherwise walks there.
fn gather(
&mut self,
person: &Person,
kind: u8,
target: (usize, usize),
room: u32,
) -> ((usize, usize), Goods) {
let pos = person.position();
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 person.tools > 0 || self.tools >= self.population_u32() {
self.config.person_tooled_gather_yield
} else {
self.config.person_gather_yield
};
let room = u16::try_from(room).unwrap_or(u16::MAX);
let take = yield_n.min(self.res[idx].1).min(room);
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_of(p);
let at_home = self.at_home(p);
let activity = |action: &str, position| Activity::new(action, position, Goods::default());
let on_trip = p.job.as_ref().is_some_and(Job::on_trip);
let food_at_home = self.food > 0 || !self.owns(home);
if p.hunger > self.config.hunger_return_threshold && food_at_home && !at_home && !on_trip {
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()),
employed: p.job.is_some(),
work_time: self.work_time(),
shift_done: p
.job
.as_ref()
.is_some_and(|job| job.shift_done(self.today())),
on_shift: p.job.as_ref().is_some_and(Job::on_shift),
on_trip,
coins: i64::from(p.coins),
..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) {
("work", ..) if p.job.is_some() => self.work(p, census),
("rest", ..) => activity("resting", pos),
("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 room = self.capacity(p).saturating_sub(p.load());
let (position, gathered) = self.gather(p, kind, target, room);
Activity::new(label, 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<AnimalId> {
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<AnimalId>,
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 seed(&self) -> u64 {
self.seed
}
pub fn snapshot_json(&self) -> Result<String, serde_json::Error> {
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),
workplaces: self.observe_workplaces(),
markets: self.observe_markets(),
carts: census.carts,
})
}
#[cfg(test)]
pub fn script(&self) -> &str {
self.script.source()
}
#[cfg(test)]
pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result<Self, String> {
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<Self, String> {
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<u32> = 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);
}
if saved.markets.is_empty() && saved.workplaces.is_empty() {
world.found_economy();
} else {
for market in saved.markets {
world.spawn_market_actor(market);
}
for workplace in saved.workplaces {
world.spawn_workplace_actor(workplace);
}
for cart in saved.carts {
world.spawn_cart_actor(cart);
}
world.refresh_board();
}
Ok(world)
}
fn rect(&self, t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> {
let n: Vec<i64> = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect();
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),
"coins" => PersonEdit::Coins(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<u32> = 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<String> = self
.houses
.iter()
.map(|h| format!("[{},{}]", h.0, h.1))
.collect();
s.push_str(&j.join(","));
s.push_str("],\"people\":[");
let j: Vec<String> = 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()),
p.coins,
p.job.as_ref().map_or_else(
|| "null".to_string(),
|job| format!("\"{}\"", job.trade.key())
)
)
})
.collect();
s.push_str(&j.join(","));
s.push_str("],\"rabbits\":[");
let j: Vec<String> = 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<String> = 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
);
}
}
self.write_economy_json(s);
s.push_str("],\"log\":[");
let j: Vec<String> = 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 console(&self) -> picard::console::Console {
self.actor_system.console()
}
/// Lets the Picard console reach the actors of another shard as `@name`.
#[cfg(feature = "console")]
pub fn register_console_peer(
&self,
name: &str,
sender: picard::console::ConsolePeerSender,
query: picard::console::ConsolePeerQuery,
) {
self.actor_system.register_console_peer(name, sender);
self.actor_system.register_console_peer_query(name, query);
}
}
/// The interface the cluster uses to drive a shard.
impl World {
pub fn tick(&self) -> u64 {
self.tick
}
/// Takes in entities handed over by other shards, so that a snapshot includes them.
pub fn admit_arrivals(&mut self) {
self.receive_arrivals();
}
pub fn shard(&self) -> Shard {
self.shard
}
pub fn router(&self) -> &Arc<Router> {
&self.router
}
/// Prices of this shard's markets, for the gateway to publish to every shard.
pub fn quotes(&self) -> Vec<Quote> {
let local: HashSet<EntityKey> = self
.market_actors
.keys()
.map(|&id| EntityKey::new(Kind::Market, id))
.collect();
self.board
.iter()
.filter(|quote| local.contains(&quote.market))
.cloned()
.collect()
}
/// Replaces the prices of other shards' markets with the ones the gateway published.
pub fn set_board(&mut self, quotes: Vec<Quote>) {
let local: HashSet<EntityKey> = self
.market_actors
.keys()
.map(|&id| EntityKey::new(Kind::Market, id))
.collect();
self.board.retain(|quote| local.contains(&quote.market));
self.board
.extend(quotes.into_iter().filter(|q| !local.contains(&q.market)));
}
/// 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<EntityKey> {
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));
let workplaces = self
.workplace_actors
.keys()
.map(|&id| EntityKey::new(Kind::Workplace, id));
let markets = self
.market_actors
.keys()
.map(|&id| EntityKey::new(Kind::Market, id));
let carts = self
.cart_actors
.keys()
.map(|&id| EntityKey::new(Kind::Cart, id));
people
.chain(rabbits)
.chain(wolves)
.chain(workplaces)
.chain(markets)
.chain(carts)
.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::economy::{Cart, Good, Job, MarketMsg, Phase, Trade, Workplace};
use super::router::Kind;
use super::{
Animal, CROP, DEFAULT_SCRIPT, EntityKey, FORWARDING_TICKS, H, LifeStage, Person, 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<Vec<Weak<Router>>>,
}
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<World> {
let loopback = Arc::new(Loopback::default());
let shards: Vec<World> = (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::<Vec<_>>()
.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 economy_script() -> SimScript {
let source = [
("WIDTH", "96"),
("HEIGHT", "64"),
("TICKS_PER_DAY", "240"),
("INITIAL_POPULATION", "30"),
("INITIAL_YOUNG_ADULTS", "30"),
("INITIAL_RABBITS", "200"),
("INITIAL_FOOD", "600"),
]
.into_iter()
.fold(DEFAULT_SCRIPT.to_string(), |source, (name, value)| {
with_constant(&source, name, value)
});
SimScript::load(&source).unwrap()
}
fn wait_until(mut done: impl FnMut() -> bool, what: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
while !done() {
assert!(Instant::now() < deadline, "{what} never happened");
std::thread::sleep(Duration::from_millis(10));
}
}
/// Moves the clock to the middle of the working hours of the current day.
fn set_work_time(world: &mut World) {
let day = world.calendar.ticks_per_day;
world.tick = world.tick / day * day + day * 45 / 100;
assert!(world.work_time());
}
/// A grown-up of `world` employed at `employer`, standing at `position` in `phase`.
fn worker(
world: &World,
employer: EntityKey,
phase: Phase,
position: (usize, usize),
) -> Person {
let markets = world.observe_markets();
let (trade, site, market) = match employer.kind {
Kind::Market => {
let market = markets.iter().find(|m| m.key() == employer).unwrap();
(Trade::Merchant, market.site(), market.clone())
}
_ => {
let workplace = world
.observe_workplaces()
.into_iter()
.find(|w| w.key() == employer)
.unwrap();
let market = markets
.iter()
.find(|m| m.key() == workplace.market)
.unwrap();
(workplace.trade, workplace.site(), market.clone())
}
};
let mut person = world
.observe_people()
.into_iter()
.find(|person| person.stage.can_partner() && person.job.is_none())
.unwrap_or_else(|| world.observe_people()[0].clone());
let mut job = Job::new(employer, trade, site, &market);
job.phase = phase;
person.job = Some(job);
(person.x, person.y) = position;
person.hunger = 0.0;
person
}
fn workplace_of(world: &World, trade: Trade) -> Workplace {
world
.observe_workplaces()
.into_iter()
.find(|workplace| workplace.trade == trade)
.unwrap()
}
#[test]
fn every_shard_founds_markets_and_workplaces_and_hires_staff() {
let world = World::new_with_script(42, economy_script());
let markets = world.observe_markets();
let workplaces = world.observe_workplaces();
assert_eq!(markets.len(), world.config.trade_merchant.count);
for trade in Trade::WORKSHOPS {
assert!(
workplaces.iter().any(|w| w.trade == trade),
"no {} was founded",
trade.key()
);
}
let farm = workplace_of(&world, Trade::Farm);
assert!(!farm.fields.is_empty());
for &(x, y) in &farm.fields {
assert_eq!(world.res[y * world.width + x].0, CROP);
}
let employed = world
.observe_people()
.iter()
.filter(|person| person.job.is_some())
.count();
let staffed: usize = workplaces
.iter()
.map(|w| w.staff.employees.len())
.chain(markets.iter().map(|m| m.staff.employees.len()))
.sum();
assert!(employed > 0);
assert_eq!(employed, staffed);
}
#[test]
fn workers_finish_their_shifts_sell_goods_and_get_paid() {
let mut world = World::new_with_script(42, economy_script());
let start: u32 = world.observe_people().iter().map(|p| p.coins).sum();
let days = 3;
while world.tick < world.calendar.ticks_per_day * days {
world.step();
}
let people = world.observe_people();
assert!(
people
.iter()
.filter_map(|person| person.job.as_ref())
.any(|job| job.day.is_some()),
"nobody finished a shift: {:?}",
people
.iter()
.filter(|p| p.job.is_some())
.map(|p| (p.id, p.action.as_str(), p.job.as_ref().unwrap().phase))
.collect::<Vec<_>>()
);
let stock: u32 = world
.observe_markets()
.iter()
.map(|market| market.stock.total())
.sum();
assert!(stock > 0, "no goods reached the markets");
let earned: u32 = people.iter().map(|p| p.coins).sum();
assert_ne!(earned, start, "nobody earned or spent anything");
}
#[test]
fn farmers_harvest_ripe_crops_and_sow_empty_fields() {
let mut world = World::new_with_script(42, economy_script());
set_work_time(&mut world);
let farm = workplace_of(&world, Trade::Farm);
for &(x, y) in &farm.fields {
world.res[y * world.width + x] = (CROP, 1);
}
let field = farm.fields[0];
let index = field.1 * world.width + field.0;
world.res[index] = (CROP, world.config.crop_ripe);
let mut farmer = worker(&world, farm.key(), Phase::Work, field);
let mut census = world.census();
let harvest = world.work(&farmer, &mut census);
assert_eq!(harvest.action, "harvesting");
assert_eq!(world.res[index], (0, 0));
let job = harvest.job.unwrap();
assert_eq!(job.goods.food, world.config.crop_yield);
assert_eq!(farmer.food, 0, "the harvest belongs to the farm");
farmer.job = Some(job);
let sow = world.work(&farmer, &mut census);
assert_eq!(sow.action, "sowing");
assert_eq!(world.res[index], (CROP, 1));
world.grow_crops();
assert_eq!(world.res[index], (CROP, 2));
world.regrow();
assert_eq!(world.res[index].0, CROP, "fields don't grow wild");
}
#[test]
fn cartwrights_build_carts_that_others_buy_for_more_capacity() {
let mut world = World::new_with_script(42, economy_script());
set_work_time(&mut world);
let shop = workplace_of(&world, Trade::Cartwright);
let mut cartwright = worker(&world, shop.key(), Phase::Craft, shop.site());
cartwright.job.as_mut().unwrap().goods.wood = world.config.cart_wood_cost;
let mut census = world.census();
let built = world.work(&cartwright, &mut census);
assert_eq!(built.action, "building a cart");
let mut job = built.job.unwrap();
let [cart] = job.wares[..] else {
panic!("no cart was built: {job:?}");
};
assert_eq!(job.goods.wood, 0);
let carts = world.observe_carts();
assert_eq!(carts.len(), 1);
assert_eq!(carts[0].owner, Some(cartwright.id));
job.phase = Phase::Sell;
cartwright.job = Some(job);
(cartwright.x, cartwright.y) = shop.market_site;
let sold = world.work(&cartwright, &mut census);
assert!(sold.job.unwrap().wares.is_empty());
let market = world.market_actors[&shop.market.id].clone();
wait_until(
|| {
world.observe_carts()[0].owner.is_none()
&& matches!(
world.ask(&market, MarketMsg::Observe),
ReplyBody::Market(m) if m.carts == vec![cart]
)
},
"the cart sale",
);
let mut buyer = worker(&world, shop.key(), Phase::Sell, shop.market_site);
buyer.id = world
.observe_people()
.iter()
.map(|p| p.id)
.find(|&id| id != cartwright.id)
.unwrap();
buyer.coins = 200;
buyer.tools = 1;
world.food = world.household_food_target();
let bought = world.work(&buyer, &mut census);
assert_eq!(bought.cart, Some(cart));
assert!(bought.spent > 0);
wait_until(
|| world.observe_carts()[0].owner == Some(buyer.id),
"the cart purchase",
);
let carrying = world.capacity(&buyer);
buyer.cart = bought.cart;
assert_eq!(
world.capacity(&buyer),
carrying + world.config.cart_capacity
);
}
#[test]
fn merchants_trade_at_markets_on_other_shards() {
let mut shards = connected_shards(2);
publish_directory(&mut shards);
let home_market = shards[0].observe_markets()[0].clone();
let far_market = shards[1].observe_markets()[0].clone();
// Food is scarce far away and plentiful at home.
let mut quotes = shards[0].quotes();
quotes.extend(shards[1].quotes());
for world in &mut shards {
world.set_board(quotes.clone());
for quote in &mut world.board {
if quote.market == home_market.key() {
quote.prices[Good::Food.index()] = 1;
}
}
}
assert_eq!(shards[0].board.len(), 2);
assert_eq!(shards[0].quotes().len(), 1);
let mut merchant = worker(
&shards[0],
home_market.key(),
Phase::Commute,
home_market.site(),
);
merchant.job.as_mut().unwrap().goods.food = 6;
let mut census = shards[0].census();
let setting_out = shards[0].work(&merchant, &mut census);
let job = setting_out.job.unwrap();
assert_eq!(job.phase, Phase::Travel);
assert_eq!(job.destination, Some((far_market.key(), far_market.site())));
// At the far market the merchant is on the other shard, which drives them now.
merchant.job = Some(job);
(merchant.x, merchant.y) = far_market.site();
let mut census = shards[1].census();
let traded = shards[1].work(&merchant, &mut census);
let job = traded.job.unwrap();
assert_eq!(job.phase, Phase::Return);
assert_eq!(job.goods.food, 0);
assert!(job.coins > 0);
let far = shards[1].market_actors[&far_market.id].clone();
let ReplyBody::Market(far_now) = shards[1].ask(&far, MarketMsg::Observe) else {
panic!("market actor did not answer");
};
assert_eq!(far_now.stock.food, 6);
// Back home, the takings settle with the home market on the first shard.
merchant.job = Some(job.clone());
(merchant.x, merchant.y) = home_market.site();
let mut census = shards[0].census();
let settled = shards[0].work(&merchant, &mut census);
assert_eq!(settled.job.unwrap().phase, Phase::Done);
let home = shards[0].market_actors[&home_market.id].clone();
wait_until(
|| {
matches!(
shards[0].ask(&home, MarketMsg::Observe),
ReplyBody::Market(m) if m.coins >= home_market.coins + job.coins - shards[0].config.merchant_escrow
)
},
"the merchant's settlement",
);
}
#[test]
fn markets_workplaces_and_carts_survive_snapshots() {
let mut world = World::new_with_script(42, economy_script());
let shop = workplace_of(&world, Trade::Cartwright);
world.spawn_cart_actor(Cart {
id: 99,
x: shop.x,
y: shop.y,
owner: None,
wear: 5,
broken: false,
});
let restored = World::from_snapshot_with_script(
&world.snapshot_json().unwrap(),
42,
economy_script(),
Shard::SINGLE,
)
.unwrap();
assert_eq!(
serde_json::to_value(restored.observe_markets()).unwrap(),
serde_json::to_value(world.observe_markets()).unwrap()
);
assert_eq!(
serde_json::to_value(restored.observe_workplaces()).unwrap(),
serde_json::to_value(world.observe_workplaces()).unwrap()
);
assert_eq!(restored.observe_carts()[0].wear, 5);
assert_eq!(restored.fields, world.fields);
let state: serde_json::Value = serde_json::from_str(&restored.state_json()).unwrap();
assert_eq!(state["carts"][0][0], 99);
assert!(state["markets"].as_array().is_some_and(|m| !m.is_empty()));
}
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<EntityKey> = 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::<Vec<_>>(),
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::<serde_json::Value>(&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!(super::regenerated_seed(world.seed()), world.seed());
}
#[test]
fn entity_actor_messages_round_trip_through_serde() {
let message = PersonMsg::Perform(
7,
Activity::new(
"foraging",
(3, 4),
Goods {
food: 2,
stone: 1,
..Goods::default()
},
),
);
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::<Vec<_>>()
.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());
}
}