diff --git a/Dockerfile b/Dockerfile index b5a9f99..9506989 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,9 +13,11 @@ RUN apt-get update \ && mkdir -p /data COPY --from=builder /src/target/release/sim /usr/local/bin/sim +COPY --from=builder /src/config.rn /etc/sim/config.rn ENV SIM_ADDR=0.0.0.0:7878 ENV DATABASE_URL=sqlite:///data/sim.db?mode=rwc +ENV SIM_CONFIG=/etc/sim/config.rn VOLUME ["/data"] EXPOSE 7878 diff --git a/README.md b/README.md index 99d7bf9..981ac1a 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,13 @@ A browser-based world simulation with a Rust server and a Leptos/Wasm UI. The se Living simulation entities (people, rabbits, and wolves) run as Picard actors. The world coordinates terrain, shared resources, and actor messages; Rune behavior scripts remain on the simulation thread because Rune's VM is not `Send`. Picard's tracing and runtime statistics features are enabled: actor lifecycle events are logged (configure verbosity with `RUST_LOG`, for example `RUST_LOG=picard=trace`), and cumulative actor/message counters are shown in the UI. -The **Calendar and life-stage durations** panel configures ticks per day and the number of days, weeks, and months in larger calendar units. Each human life stage has an inclusive minimum/maximum duration in in-game days; the world samples within that range when a person enters the stage. These settings are stored with the world snapshot. +The simulation script is the root [config.rn](./config.rn), a single Rune file. Its `pub const` items hold defaults and tuning values: map dimensions and terrain generation, initial population and resources, calendar and life-stage defaults, hunger, gathering, construction, partnerships and births, animal behavior, and edit limits. Its `pub fn person`, `pub fn rabbit`, and `pub fn wolf` items are the agent behaviours, and they can use the constants directly. Every constant the server reads must be defined; missing or invalid values are reported at startup. `SIM_CONFIG` can point to a different Rune file. Docker Compose mounts the repository's `config.rn` read-only into the app container. -New people, including babies, start as toddlers and age through child, teen, young adult, adult, older adult, elderly, and death. The starting village is seeded with young and mature adults and a reserve of 60 food so the simulation can work and form the first partnerships immediately. People pathfind around blocked terrain when travelling home for food. Unpartnered people from young adulthood onward can form a single partnership with a nearby eligible person. Each partnership can have one toddler per calendar week when both partners are in stages marked fertile, the stockpile has enough food, and housing capacity permits it. The starting village has capacity for 12 people, with each additional house increasing capacity by three. Young adult and adult stages are fertile by default; fertility can be changed per stage in the calendar panel. Partnerships are Picard actors as well as people and animals. +The in-browser script editor edits the same file contents. Applied edits take effect immediately for behaviours and tuning values, while map dimensions, initial population, and calendar defaults apply when the world is reset. Editor changes are not written back to disk: on restart, `config.rn` from disk replaces them, even when a saved world snapshot is restored. + +The **Calendar and life-stage durations** panel configures ticks per day and the number of days, weeks, and months in larger calendar units. Each human life stage has an inclusive minimum/maximum duration in in-game days; the world samples within that range when a person enters the stage. These runtime settings are stored with the world snapshot and take precedence over the config defaults until the world is reset. + +New people, including babies, start as toddlers and age through child, teen, young adult, adult, older adult, elderly, and death. By default, the starting village has nine young and mature adults and a reserve of 60 food so it can form partnerships and sustain its population. People pathfind around blocked terrain when travelling home for food. Unpartnered people from young adulthood onward can form a single partnership with a nearby eligible person. Each partnership can have one toddler per calendar week when both partners are in stages marked fertile, the stockpile has enough food, and housing capacity permits it. By default, the village has capacity for 12 people, with each additional house increasing capacity by three; these values are configurable. Young adult and adult stages are fertile by default; fertility can be changed per stage in the calendar panel. Partnerships are Picard actors as well as people and animals. The UI is built automatically as part of `cargo run` and embedded in the server binary. There is no separate manual UI build step. @@ -88,7 +92,7 @@ The simulation is saved as a snapshot about once per second and restored at star - `src/world.rs` contains the simulation and snapshot serialization. - `src/main.rs` runs the Axum server, simulation thread, and HTTP/WebSocket API. - `src/storage.rs` provides SQLite and Postgres snapshot storage. -- `src/behavior.rn` is the default Rune behavior script. +- `config.rn` is the Rune simulation script: `pub const` defaults and tuning values plus `pub fn` behaviours. - `ui/` contains the Leptos frontend, compiled to Wasm by `build.rs`. - `Dockerfile`, `compose.yaml`, and `Justfile` provide container and developer workflows. diff --git a/compose.yaml b/compose.yaml index 92dbac6..762ac7d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,10 @@ services: build: . environment: SIM_ADDR: 0.0.0.0:7878 + SIM_CONFIG: /etc/sim/config.rn DATABASE_URL: postgres://sim:sim@db:5432/sim + volumes: + - ./config.rn:/etc/sim/config.rn:ro ports: - "7878:7878" depends_on: diff --git a/config.rn b/config.rn new file mode 100644 index 0000000..c71476f --- /dev/null +++ b/config.rn @@ -0,0 +1,206 @@ +// Simulation script: `pub const` items are defaults and tuning constants, +// `pub fn` items are entity behaviours. Every constant below is required. + +// World +pub const WIDTH = 96; +pub const HEIGHT = 64; + +// Calendar +pub const TICKS_PER_DAY = 1800; +pub const DAYS_PER_WEEK = 7; +pub const WEEKS_PER_MONTH = 4; +pub const MONTHS_PER_YEAR = 12; + +// Life stages: days spent in each stage; fertile stages can have children +pub const TODDLER = #{ min_days: 2, max_days: 4, fertile: false }; +pub const CHILD = #{ min_days: 4, max_days: 8, fertile: false }; +pub const TEEN = #{ min_days: 4, max_days: 8, fertile: false }; +pub const YOUNG_ADULT = #{ min_days: 6, max_days: 12, fertile: true }; +pub const ADULT = #{ min_days: 20, max_days: 40, fertile: true }; +pub const OLDER_ADULT = #{ min_days: 10, max_days: 20, fertile: false }; +pub const ELDERLY = #{ min_days: 4, max_days: 10, fertile: false }; + +// Initial world +pub const INITIAL_POPULATION = 9; +pub const INITIAL_YOUNG_ADULTS = 3; +pub const INITIAL_RABBITS = 60; +pub const INITIAL_WOLVES = 4; +pub const INITIAL_FOOD = 60; +pub const INITIAL_PERSON_HUNGER = 10.0; +pub const INITIAL_ANIMAL_ENERGY = 70.0; +pub const ANIMAL_ENERGY_DEATH_THRESHOLD = 0.0; +pub const STARTING_TICK_FRACTION = 4; + +// Population and housing +pub const POPULATION_CAPACITY = 12; +pub const HOUSE_CAPACITY = 3; +pub const MAX_HOUSES = 10; + +// Daylight +pub const DAYLIGHT_NIGHT_THRESHOLD = 0.2; +pub const DAYLIGHT_BASE = 0.5; +pub const DAYLIGHT_AMPLITUDE = 0.8; + +// Movement and update cadence +pub const HOME_RADIUS = 1; +pub const ATTACK_RANGE = 1; +pub const ENTITY_UPDATE_INTERVAL = 2; + +// Hunger +pub const HUNGER_NIGHT_AT_HOME = 0.01; +pub const HUNGER_OTHER = 0.02; +pub const HUNGER_EAT_HOME_THRESHOLD = 40.0; +pub const HUNGER_EAT_CARRIED_THRESHOLD = 50.0; +pub const HUNGER_RETURN_THRESHOLD = 40.0; +pub const HUNGER_STARVATION_THRESHOLD = 100.0; +pub const HUNGER_MEAL_REDUCTION = 25.0; +pub const FOOD_PER_MEAL = 1; + +// People: gathering and hunting +pub const RESOURCE_SEARCH_RADIUS = 25; +pub const PERSON_HUNT_SUCCESS_CHANCE = 0.5; +pub const PERSON_HUNT_FOOD_YIELD = 3; +pub const PERSON_GATHER_YIELD = 1; +pub const PERSON_TOOLED_GATHER_YIELD = 2; + +// Partnerships and births +pub const PARTNERSHIP_DISTANCE = 12; +pub const BIRTH_FOOD_THRESHOLD = 10; +pub const BIRTH_FOOD_COST = 6; + +// Construction +pub const TOOL_WOOD_COST = 3; +pub const TOOL_STONE_COST = 1; +pub const HOUSE_WOOD_COST = 12; +pub const HOUSE_STONE_COST = 4; +pub const HOUSE_PLACEMENT_RADIUS = 6; + +// Resources +pub const RESOURCE_REGROW_ATTEMPTS = 20; +pub const INITIAL_TREE_CHANCE = 0.6; +pub const INITIAL_TREE_AMOUNT = 5; +pub const INITIAL_BERRY_CHANCE = 0.04; +pub const INITIAL_BERRY_AMOUNT = 4; +pub const INITIAL_STONE_CHANCE = 0.15; +pub const INITIAL_STONE_AMOUNT = 6; +pub const TREE_REGROW_CHANCE = 0.02; +pub const TREE_REGROW_AMOUNT = 5; +pub const BERRY_REGROW_CHANCE = 0.02; +pub const BERRY_REGROW_AMOUNT = 4; +pub const STONE_REGROW_CHANCE = 0.004; +pub const STONE_REGROW_AMOUNT = 6; + +// Animal spawning +pub const ANIMAL_POPULATION_INTERVAL = 50; +pub const RABBIT_SPAWN_CAP = 15; +pub const WOLF_SPAWN_CAP = 2; +pub const WOLF_SPAWN_RABBIT_THRESHOLD = 40; +pub const WOLF_MIN_HOME_DISTANCE = 15; + +// Rabbits +pub const RABBIT_ENERGY_DRAIN = 0.05; +pub const RABBIT_ENERGY_REGEN = 0.2; +pub const RABBIT_ENERGY_MAX = 100.0; +pub const RABBIT_WOLF_DETECTION_RANGE = 4; +pub const RABBIT_BREED_ENERGY = 80.0; +pub const RABBIT_MAX_POPULATION = 120; +pub const RABBIT_BREED_CHANCE = 0.01; +pub const RABBIT_BREED_ENERGY_COST = 30.0; +pub const RABBIT_NEWBORN_ENERGY = 50.0; + +// Wolves +pub const WOLF_ENERGY_DRAIN = 0.15; +pub const WOLF_PREY_RANGE = 10; +pub const WOLF_HUNT_SUCCESS_CHANCE = 0.4; +pub const WOLF_HUNT_ENERGY_GAIN = 50.0; +pub const WOLF_ENERGY_MAX = 100.0; +pub const WOLF_BREED_ENERGY = 90.0; +pub const WOLF_MAX_POPULATION = 10; +pub const WOLF_BREED_CHANCE = 0.02; +pub const WOLF_BREED_ENERGY_COST = 40.0; +pub const WOLF_NEWBORN_ENERGY = 50.0; + +// Terrain generation +pub const TERRAIN_NOISE_OCTAVES = 4; +pub const TERRAIN_NOISE_SCALE = 16.0; +pub const TERRAIN_EDGE_HEIGHT = 0.25; +pub const TERRAIN_EDGE_FALLOFF = 0.55; +pub const TERRAIN_EDGE_DISTANCE_CAP = 1.5; +pub const TERRAIN_MOISTURE_OFFSET = 500.0; +pub const TERRAIN_MOISTURE_SEED_OFFSET = 99; +pub const TERRAIN_DEEP_WATER_THRESHOLD = 0.28; +pub const TERRAIN_WATER_THRESHOLD = 0.36; +pub const TERRAIN_SAND_THRESHOLD = 0.41; +pub const TERRAIN_FOREST_GRASS_THRESHOLD = 0.68; +pub const TERRAIN_HILLS_THRESHOLD = 0.78; +pub const TERRAIN_FOREST_MOISTURE_THRESHOLD = 0.52; + +// Misc +pub const RANDOM_LAND_ATTEMPTS = 50; +pub const LOG_ENTRY_LIMIT = 8; +pub const WANDER_ATTEMPTS = 4; +pub const WANDER_RADIUS = 1; + +// UI edit limits +pub const MANUAL_SPAWN_LIMIT = 25; +pub const MAX_SIMULATION_SPEED = 8; +pub const MAX_STOCK_EDIT = 9999; +pub const MAX_PERSON_INVENTORY_EDIT = 99; +pub const MAX_HUNGER_EDIT = 99; +pub const MAX_ANIMAL_ENERGY_EDIT = 100.0; +pub const MAX_RESOURCE_EDIT = 50; + +// Behaviour tuning (used only by the behaviour functions below) +pub const HUNGER_SCRIPT_THRESHOLD = 50.0; +pub const PERSON_CARRY_LIMIT = 6; +pub const PERSON_FOOD_STOCK_THRESHOLD = 12; +pub const PERSON_HUNT_RABBIT_CHANCE = 0.3; +pub const PERSON_WOOD_STONE_RATIO = 2; +pub const PERSON_WOOD_BUFFER = 8; +pub const RABBIT_BEHAVIOR_FLEE_CHANCE = 0.8; +pub const RABBIT_BEHAVIOR_WANDER_CHANCE = 0.5; + +// Behaviours. Each function receives what the entity senses and returns the +// name of an action; the Rust side carries it out. + +// person: sleep | go_home | eat | haul | hunt | forage | chop | mine | wander +pub fn person(c) { + if c.night { + return if c.at_home { "sleep" } else { "go_home" }; + } + if c.hunger > HUNGER_SCRIPT_THRESHOLD && c.carried_food == 0 && c.stock_food > 0 { + return "eat"; + } + if c.carry >= PERSON_CARRY_LIMIT { + return "haul"; + } + if c.stock_food < PERSON_FOOD_STOCK_THRESHOLD || c.hunger > HUNGER_SCRIPT_THRESHOLD { + if c.rabbit_near && (!c.berry_near || c.roll < PERSON_HUNT_RABBIT_CHANCE) { + return "hunt"; + } + if c.berry_near { + return "forage"; + } + } + if c.stock_wood <= c.stock_stone * PERSON_WOOD_STONE_RATIO + PERSON_WOOD_BUFFER { + if c.tree_near { "chop" } else if c.stone_near { "mine" } else { "wander" } + } else { + if c.stone_near { "mine" } else if c.tree_near { "chop" } else { "wander" } + } +} + +// rabbit: flee | wander | stay +pub fn rabbit(c) { + if c.wolf_near { + if c.roll < RABBIT_BEHAVIOR_FLEE_CHANCE { "flee" } else { "stay" } + } else if c.roll < RABBIT_BEHAVIOR_WANDER_CHANCE { + "wander" + } else { + "stay" + } +} + +// wolf: hunt | wander +pub fn wolf(c) { + if c.prey_near { "hunt" } else { "wander" } +} diff --git a/src/behavior.rn b/src/behavior.rn deleted file mode 100644 index d67ee71..0000000 --- a/src/behavior.rn +++ /dev/null @@ -1,44 +0,0 @@ -// Behaviour scripts. Each function receives what the entity senses and -// returns the name of an action; the Rust side carries it out. - -// person: sleep | go_home | eat | haul | hunt | forage | chop | mine | wander -pub fn person(c) { - if c.night { - return if c.at_home { "sleep" } else { "go_home" }; - } - if c.hunger > 50.0 && c.carried_food == 0 && c.stock_food > 0 { - return "eat"; - } - if c.carry >= 6 { - return "haul"; - } - if c.stock_food < 12 || c.hunger > 50.0 { - if c.rabbit_near && (!c.berry_near || c.roll < 0.3) { - return "hunt"; - } - if c.berry_near { - return "forage"; - } - } - if c.stock_wood <= c.stock_stone * 2 + 8 { - if c.tree_near { "chop" } else if c.stone_near { "mine" } else { "wander" } - } else { - if c.stone_near { "mine" } else if c.tree_near { "chop" } else { "wander" } - } -} - -// rabbit: flee | wander | stay -pub fn rabbit(c) { - if c.wolf_near { - if c.roll < 0.8 { "flee" } else { "stay" } - } else if c.roll < 0.5 { - "wander" - } else { - "stay" - } -} - -// wolf: hunt | wander -pub fn wolf(c) { - if c.prey_near { "hunt" } else { "wander" } -} diff --git a/src/index.html b/src/index.html index 3e67c0b..357a905 100644 --- a/src/index.html +++ b/src/index.html @@ -118,7 +118,7 @@
- behavior.rn + config.rn diff --git a/src/main.rs b/src/main.rs index 960efb4..2cae2ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use storage::Storage; use tokio::sync::{mpsc, oneshot, watch}; -use world::{DEFAULT_SCRIPT, World}; +use world::{SimScript, World}; // The UI bundle that build.rs produced from ui/. static UI: Dir = include_dir!("$OUT_DIR/ui-pkg"); @@ -43,10 +43,19 @@ struct App { tx: mpsc::UnboundedSender, } +struct SimStart { + seed: u64, + script: SimScript, + snapshot: Option, +} + /// The Rune VM is not Send, so the world lives on this thread and publishes JSON snapshots. fn run_sim( - seed: u64, - snapshot: Option, + SimStart { + seed, + script: sim_script, + snapshot, + }: SimStart, mut rx: mpsc::UnboundedReceiver, terrain: Arc>, script: Arc>, @@ -54,9 +63,9 @@ fn run_sim( snapshot_tx: watch::Sender>, ) { let mut world = match snapshot { - Some(snapshot) => World::from_snapshot(&snapshot, seed) + Some(snapshot) => World::from_snapshot_with_script(&snapshot, seed, sim_script) .unwrap_or_else(|e| panic!("failed to restore simulation snapshot: {e}")), - None => World::new(seed), + None => World::new_with_script(seed, sim_script), }; *script.lock().unwrap() = world.script().to_string(); let mut rev = u32::MAX; @@ -247,6 +256,11 @@ async fn main() { .nth(1) .and_then(|s| s.parse().ok()) .unwrap_or(42u64); + let config_path = std::env::var("SIM_CONFIG").unwrap_or_else(|_| "config.rn".into()); + let config_source = std::fs::read_to_string(&config_path) + .unwrap_or_else(|e| panic!("failed to read simulation config at {config_path}: {e}")); + let sim_script = SimScript::load(&config_source) + .unwrap_or_else(|e| panic!("failed to load simulation config at {config_path}: {e}")); let addr = std::env::var("SIM_ADDR").unwrap_or_else(|_| "127.0.0.1:7878".into()); let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://sim.db?mode=rwc".into()); @@ -282,15 +296,18 @@ async fn main() { }); let terrain_buf = Arc::new(Mutex::new(String::new())); - let script = Arc::new(Mutex::new(DEFAULT_SCRIPT.to_string())); + let script = Arc::new(Mutex::new(config_source)); let (tx, rx) = mpsc::unbounded_channel(); let (state_tx, state_rx) = watch::channel(Arc::::from("{}")); { let (terrain_buf, script) = (Arc::clone(&terrain_buf), Arc::clone(&script)); std::thread::spawn(move || { run_sim( - seed, - saved_snapshot, + SimStart { + seed, + script: sim_script, + snapshot: saved_snapshot, + }, rx, terrain_buf, script, diff --git a/src/world.rs b/src/world.rs index cc3dc33..91d8e23 100644 --- a/src/world.rs +++ b/src/world.rs @@ -2,23 +2,24 @@ use picard::actors::{ Actor, ActorFactoryArgs, ActorRef, ActorRefFactory, ActorReference, ActorSystem, Context as ActorContext, Sender, Tell, }; +use rune::runtime::RuntimeContext; use rune::termcolor::Buffer; -use rune::{Any, Context, Diagnostics, Module, Source, Sources, Vm}; +use rune::{Any, Context, Diagnostics, Module, Source, Sources, Unit, Vm}; use serde::{Deserialize, Serialize}; use std::cmp::Reverse; use std::collections::{BinaryHeap, HashMap, HashSet}; use std::fmt::Write; use std::sync::{ - Arc, + Arc, OnceLock, mpsc::{SyncSender, sync_channel}, }; use std::time::Duration; pub const W: usize = 96; pub const H: usize = 64; -pub const DEFAULT_TICKS_PER_DAY: u64 = 1800; +pub const DEFAULT_SCRIPT: &str = include_str!("../config.rn"); -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, Serialize, rune::FromValue)] pub struct StageDuration { pub min_days: u32, pub max_days: u32, @@ -26,7 +27,7 @@ pub struct StageDuration { pub fertile: bool, } -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, rune::FromValue)] #[serde(default)] pub struct CalendarSettings { pub ticks_per_day: u64, @@ -54,50 +55,526 @@ impl Default for StageDuration { impl Default for CalendarSettings { fn default() -> Self { - Self { - ticks_per_day: DEFAULT_TICKS_PER_DAY, - days_per_week: 7, - weeks_per_month: 4, - months_per_year: 12, - toddler: StageDuration { - min_days: 2, - max_days: 4, - fertile: false, - }, - child: StageDuration { - min_days: 4, - max_days: 8, - fertile: false, - }, - teen: StageDuration { - min_days: 4, - max_days: 8, - fertile: false, - }, - young_adult: StageDuration { - min_days: 6, - max_days: 12, - fertile: true, - }, - adult: StageDuration { - min_days: 20, - max_days: 40, - fertile: true, - }, - older_adult: StageDuration { - min_days: 10, - max_days: 20, - fertile: false, - }, - elderly: StageDuration { - min_days: 4, - max_days: 10, - fertile: false, - }, - } + default_config().calendar } } +#[derive(Clone, Debug, rune::FromValue)] +pub struct SimulationConfig { + width: usize, + height: usize, + calendar: CalendarSettings, + initial_population: usize, + initial_young_adults: usize, + initial_rabbits: usize, + initial_wolves: usize, + initial_food: u32, + initial_person_hunger: f32, + initial_animal_energy: f32, + animal_energy_death_threshold: f32, + starting_tick_fraction: u64, + population_capacity: usize, + house_capacity: usize, + max_houses: usize, + daylight_night_threshold: f32, + daylight_base: f32, + daylight_amplitude: f32, + home_radius: usize, + attack_range: usize, + entity_update_interval: u64, + hunger_night_at_home: f32, + hunger_other: f32, + hunger_eat_home_threshold: f32, + hunger_eat_carried_threshold: f32, + hunger_return_threshold: f32, + hunger_starvation_threshold: f32, + hunger_meal_reduction: f32, + food_per_meal: u32, + resource_search_radius: usize, + person_hunt_success_chance: f32, + person_hunt_food_yield: u32, + person_gather_yield: u16, + person_tooled_gather_yield: u16, + partnership_distance: usize, + birth_food_threshold: u32, + birth_food_cost: u32, + tool_wood_cost: u32, + tool_stone_cost: u32, + house_wood_cost: u32, + house_stone_cost: u32, + house_placement_radius: usize, + resource_regrow_attempts: usize, + initial_tree_chance: f32, + initial_tree_amount: u16, + initial_berry_chance: f32, + initial_berry_amount: u16, + initial_stone_chance: f32, + initial_stone_amount: u16, + tree_regrow_chance: f32, + tree_regrow_amount: u16, + berry_regrow_chance: f32, + berry_regrow_amount: u16, + stone_regrow_chance: f32, + stone_regrow_amount: u16, + animal_population_interval: u64, + rabbit_spawn_cap: usize, + wolf_spawn_cap: usize, + wolf_spawn_rabbit_threshold: usize, + wolf_min_home_distance: usize, + rabbit_energy_drain: f32, + rabbit_energy_regen: f32, + rabbit_energy_max: f32, + rabbit_wolf_detection_range: usize, + rabbit_breed_energy: f32, + rabbit_max_population: usize, + rabbit_breed_chance: f32, + rabbit_breed_energy_cost: f32, + rabbit_newborn_energy: f32, + wolf_energy_drain: f32, + wolf_prey_range: usize, + wolf_hunt_success_chance: f32, + wolf_hunt_energy_gain: f32, + wolf_energy_max: f32, + wolf_breed_energy: f32, + wolf_max_population: usize, + wolf_breed_chance: f32, + wolf_breed_energy_cost: f32, + wolf_newborn_energy: f32, + terrain_noise_octaves: usize, + terrain_noise_scale: f32, + terrain_edge_height: f32, + terrain_edge_falloff: f32, + terrain_edge_distance_cap: f32, + terrain_moisture_offset: f32, + terrain_moisture_seed_offset: u64, + terrain_deep_water_threshold: f32, + terrain_water_threshold: f32, + terrain_sand_threshold: f32, + terrain_forest_grass_threshold: f32, + terrain_hills_threshold: f32, + terrain_forest_moisture_threshold: f32, + random_land_attempts: usize, + log_entry_limit: usize, + wander_attempts: usize, + wander_radius: usize, + manual_spawn_limit: usize, + max_simulation_speed: u32, + max_stock_edit: u32, + max_person_inventory_edit: u32, + max_hunger_edit: i64, + max_animal_energy_edit: f32, + max_resource_edit: u16, +} + +impl SimulationConfig { + fn validate(&self) -> Result<(), String> { + 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()); + } + + 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()); + } + } + 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")); + } + } + 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")); + } + } + if self.birth_food_cost > self.birth_food_threshold { + return Err("birth_food_cost cannot exceed birth_food_threshold".into()); + } + for (name, value) in [ + ("initial_person_hunger", self.initial_person_hunger), + ("initial_animal_energy", self.initial_animal_energy), + ( + "animal_energy_death_threshold", + self.animal_energy_death_threshold, + ), + ("daylight_night_threshold", self.daylight_night_threshold), + ("daylight_base", self.daylight_base), + ("daylight_amplitude", self.daylight_amplitude), + ("hunger_night_at_home", self.hunger_night_at_home), + ("hunger_other", self.hunger_other), + ("hunger_eat_home_threshold", self.hunger_eat_home_threshold), + ( + "hunger_eat_carried_threshold", + self.hunger_eat_carried_threshold, + ), + ("hunger_return_threshold", self.hunger_return_threshold), + ( + "hunger_starvation_threshold", + self.hunger_starvation_threshold, + ), + ("hunger_meal_reduction", self.hunger_meal_reduction), + ("rabbit_energy_drain", self.rabbit_energy_drain), + ("rabbit_energy_regen", self.rabbit_energy_regen), + ("rabbit_energy_max", self.rabbit_energy_max), + ("wolf_energy_drain", self.wolf_energy_drain), + ("wolf_hunt_energy_gain", self.wolf_hunt_energy_gain), + ("wolf_energy_max", self.wolf_energy_max), + ("rabbit_breed_energy", self.rabbit_breed_energy), + ("rabbit_breed_energy_cost", self.rabbit_breed_energy_cost), + ("rabbit_newborn_energy", self.rabbit_newborn_energy), + ("wolf_breed_energy", self.wolf_breed_energy), + ("wolf_breed_energy_cost", self.wolf_breed_energy_cost), + ("wolf_newborn_energy", self.wolf_newborn_energy), + ] { + if !value.is_finite() || value < 0.0 { + return Err(format!("{name} must be a finite non-negative number")); + } + } + Ok(()) + } +} + +const CALENDAR_FIELDS: &[&str] = &[ + "ticks_per_day", + "days_per_week", + "weeks_per_month", + "months_per_year", + "toddler", + "child", + "teen", + "young_adult", + "adult", + "older_adult", + "elderly", +]; + +/// `SimulationConfig` fields (except `calendar`), each read from the `pub const` +/// of the same name in upper case. +const CONFIG_FIELDS: &[&str] = &[ + "width", + "height", + "initial_population", + "initial_young_adults", + "initial_rabbits", + "initial_wolves", + "initial_food", + "initial_person_hunger", + "initial_animal_energy", + "animal_energy_death_threshold", + "starting_tick_fraction", + "population_capacity", + "house_capacity", + "max_houses", + "daylight_night_threshold", + "daylight_base", + "daylight_amplitude", + "home_radius", + "attack_range", + "entity_update_interval", + "hunger_night_at_home", + "hunger_other", + "hunger_eat_home_threshold", + "hunger_eat_carried_threshold", + "hunger_return_threshold", + "hunger_starvation_threshold", + "hunger_meal_reduction", + "food_per_meal", + "resource_search_radius", + "person_hunt_success_chance", + "person_hunt_food_yield", + "person_gather_yield", + "person_tooled_gather_yield", + "partnership_distance", + "birth_food_threshold", + "birth_food_cost", + "tool_wood_cost", + "tool_stone_cost", + "house_wood_cost", + "house_stone_cost", + "house_placement_radius", + "resource_regrow_attempts", + "initial_tree_chance", + "initial_tree_amount", + "initial_berry_chance", + "initial_berry_amount", + "initial_stone_chance", + "initial_stone_amount", + "tree_regrow_chance", + "tree_regrow_amount", + "berry_regrow_chance", + "berry_regrow_amount", + "stone_regrow_chance", + "stone_regrow_amount", + "animal_population_interval", + "rabbit_spawn_cap", + "wolf_spawn_cap", + "wolf_spawn_rabbit_threshold", + "wolf_min_home_distance", + "rabbit_energy_drain", + "rabbit_energy_regen", + "rabbit_energy_max", + "rabbit_wolf_detection_range", + "rabbit_breed_energy", + "rabbit_max_population", + "rabbit_breed_chance", + "rabbit_breed_energy_cost", + "rabbit_newborn_energy", + "wolf_energy_drain", + "wolf_prey_range", + "wolf_hunt_success_chance", + "wolf_hunt_energy_gain", + "wolf_energy_max", + "wolf_breed_energy", + "wolf_max_population", + "wolf_breed_chance", + "wolf_breed_energy_cost", + "wolf_newborn_energy", + "terrain_noise_octaves", + "terrain_noise_scale", + "terrain_edge_height", + "terrain_edge_falloff", + "terrain_edge_distance_cap", + "terrain_moisture_offset", + "terrain_moisture_seed_offset", + "terrain_deep_water_threshold", + "terrain_water_threshold", + "terrain_sand_threshold", + "terrain_forest_grass_threshold", + "terrain_hills_threshold", + "terrain_forest_moisture_threshold", + "random_land_attempts", + "log_entry_limit", + "wander_attempts", + "wander_radius", + "manual_spawn_limit", + "max_simulation_speed", + "max_stock_edit", + "max_person_inventory_edit", + "max_hunger_edit", + "max_animal_energy_edit", + "max_resource_edit", +]; + +const CONFIG_ACCESSOR: &str = "__sim_config"; + +/// Rune does not expose unit constants to the host, so a generated function +/// gathers the script's `pub const` items into one object. +fn config_accessor_source() -> String { + let fields = |names: &[&str]| { + names + .iter() + .map(|name| format!("{name}: {}", name.to_uppercase())) + .collect::>() + .join(", ") + }; + format!( + "pub fn {CONFIG_ACCESSOR}() {{ #{{ {}, calendar: #{{ {} }} }} }}", + fields(CONFIG_FIELDS), + fields(CALENDAR_FIELDS) + ) +} + +/// A compiled simulation script: `pub const` defaults plus `pub fn` behaviours. +#[derive(Clone)] +pub struct SimScript { + source: String, + config: SimulationConfig, + runtime: Arc, + unit: Arc, +} + +impl SimScript { + /// Compiles the script, reads and validates its constants, and smoke-tests + /// every behaviour so errors surface at load time. + pub fn load(src: &str) -> Result { + let mut module = Module::new(); + module.ty::().map_err(|e| e.to_string())?; + let mut context = Context::with_default_modules().map_err(|e| e.to_string())?; + context.install(module).map_err(|e| e.to_string())?; + let mut sources = Sources::new(); + for (name, text) in [ + ("config", src.to_string()), + ("generated", config_accessor_source()), + ] { + sources + .insert(Source::new(name, text).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + } + let mut diagnostics = Diagnostics::new(); + let unit = rune::prepare(&mut sources) + .with_context(&context) + .with_diagnostics(&mut diagnostics) + .build(); + let unit = match unit { + Ok(unit) => unit, + Err(_) => { + let mut buffer = Buffer::no_color(); + let _ = diagnostics.emit(&mut buffer, &sources); + return Err(String::from_utf8_lossy(buffer.as_slice()).into_owned()); + } + }; + let runtime = Arc::new(context.runtime().map_err(|e| e.to_string())?); + let unit = Arc::new(unit); + let mut vm = Vm::new(Arc::clone(&runtime), Arc::clone(&unit)); + let value = vm + .call([CONFIG_ACCESSOR], ()) + .map_err(|e| format!("reading constants: {e}"))?; + let config = rune::from_value::(value) + .map_err(|e| format!("invalid constant: {e}"))?; + config.validate()?; + for f in ["person", "rabbit", "wolf"] { + let v = vm + .call([f], (Ctx::default(),)) + .map_err(|e| format!("{f}(): {e}"))?; + rune::from_value::(v).map_err(|_| format!("{f}() must return a string"))?; + } + Ok(Self { + source: src.to_string(), + config, + runtime, + unit, + }) + } + + pub fn source(&self) -> &str { + &self.source + } + + fn vm(&self) -> Vm { + Vm::new(Arc::clone(&self.runtime), Arc::clone(&self.unit)) + } +} + +fn default_script() -> SimScript { + static SCRIPT: OnceLock = OnceLock::new(); + SCRIPT + .get_or_init(|| SimScript::load(DEFAULT_SCRIPT).expect("config.rn failed to load")) + .clone() +} + +fn default_config() -> SimulationConfig { + default_script().config +} + impl CalendarSettings { fn duration(&self, stage: &str) -> Option<&StageDuration> { match stage { @@ -206,9 +683,6 @@ const MOUNTAIN: u8 = 6; const TREE: u8 = 1; const BERRY: u8 = 2; const STONE: u8 = 3; -const BASE_POPULATION_CAPACITY: usize = 12; -const STARTING_FOOD: u32 = 60; - pub struct Rng(u64); impl Rng { @@ -255,10 +729,10 @@ fn value_noise(x: f32, y: f32, seed: u64) -> f32 { top + (bot - top) * v } -fn fbm(x: f32, y: f32, seed: u64) -> f32 { - let (mut sum, mut amp, mut freq, mut norm) = (0.0, 1.0, 1.0 / 16.0, 0.0); - for o in 0..4 { - sum += amp * value_noise(x * freq, y * freq, seed + o); +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; @@ -301,42 +775,6 @@ struct Ctx { roll: f64, } -pub const DEFAULT_SCRIPT: &str = include_str!("behavior.rn"); - -/// Compiles the script and smoke-tests every entry point so errors surface at load time. -fn load_script(src: &str) -> Result { - let mut module = Module::new(); - module.ty::().map_err(|e| e.to_string())?; - let mut context = Context::with_default_modules().map_err(|e| e.to_string())?; - context.install(module).map_err(|e| e.to_string())?; - let mut sources = Sources::new(); - sources - .insert(Source::new("behavior", src).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 unit = match unit { - Ok(u) => u, - Err(_) => { - let mut buf = Buffer::no_color(); - let _ = diagnostics.emit(&mut buf, &sources); - return Err(String::from_utf8_lossy(buf.as_slice()).into_owned()); - } - }; - let runtime = context.runtime().map_err(|e| e.to_string())?; - let mut vm = Vm::new(Arc::new(runtime), Arc::new(unit)); - for f in ["person", "rabbit", "wolf"] { - let v = vm - .call([f], (Ctx::default(),)) - .map_err(|e| format!("{f}(): {e}"))?; - rune::from_value::(v).map_err(|_| format!("{f}() must return a string"))?; - } - Ok(vm) -} - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Person { #[serde(skip)] @@ -441,6 +879,8 @@ impl Actor for EntityActor { pub struct World { seed: u64, + width: usize, + height: usize, tiles: Vec, res: Vec<(u8, u16)>, people: Vec, @@ -461,18 +901,23 @@ pub struct World { tick: u64, next_id: u32, log: Vec, - script: 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, + #[serde(default = "default_width")] + width: usize, + #[serde(default = "default_height")] + height: usize, tiles: Vec, res: Vec<(u8, u16)>, people: Vec, @@ -488,7 +933,6 @@ struct WorldSnapshot { tick: u64, next_id: u32, log: Vec, - script: String, paused: bool, speed: u32, terrain_rev: u32, @@ -504,29 +948,61 @@ 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)) } impl World { + #[cfg(test)] pub fn new(seed: u64) -> World { - let mut tiles = vec![0u8; W * H]; - for y in 0..H { - for x in 0..W { - let dx = (x as f32 / W as f32 - 0.5) * 2.0; - let dy = (y as f32 / H as f32 - 0.5) * 2.0; - let d = (dx * dx + dy * dy).min(1.5); - let h = fbm(x as f32, y as f32, seed) + 0.25 - 0.55 * d; - let m = fbm(x as f32 + 500.0, y as f32 + 500.0, seed + 99); - tiles[y * W + x] = if h < 0.28 { + Self::new_with_script(seed, default_script()) + } + + pub fn new_with_script(seed: u64, script: SimScript) -> World { + let config = script.config.clone(); + 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 dx = (x as f32 / width as f32 - 0.5) * 2.0; + let dy = (y as f32 / height as f32 - 0.5) * 2.0; + let d = (dx * dx + dy * dy).min(config.terrain_edge_distance_cap); + let h = fbm( + x as f32, + y as f32, + seed, + config.terrain_noise_octaves, + config.terrain_noise_scale, + ) + config.terrain_edge_height + - config.terrain_edge_falloff * d; + let m = fbm( + x as f32 + config.terrain_moisture_offset, + y as f32 + 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 < 0.36 { + } else if h < config.terrain_water_threshold { WATER - } else if h < 0.41 { + } else if h < config.terrain_sand_threshold { SAND - } else if h < 0.68 { - if m > 0.52 { FOREST } else { GRASS } - } else if h < 0.78 { + } 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 @@ -535,8 +1011,10 @@ impl World { } let mut w = World { seed, + width, + height, tiles, - res: vec![(0, 0); W * H], + res: vec![(0, 0); width * height], people: vec![], rabbits: vec![], wolves: vec![], @@ -546,39 +1024,44 @@ impl World { entities: HashMap::new(), next_actor_id: 1, houses: vec![], - home: (W / 2, H / 2), - food: STARTING_FOOD, + home: (width / 2, height / 2), + food: config.initial_food, wood: 0, stone: 0, tools: 0, rng: Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1), - tick: DEFAULT_TICKS_PER_DAY / 4, + tick: (config.calendar.ticks_per_day / config.starting_tick_fraction).max(1), next_id: 1, log: vec![], - script: DEFAULT_SCRIPT.to_string(), - vm: load_script(DEFAULT_SCRIPT).expect("behavior.rn failed to load"), + vm: script.vm(), + script, paused: false, speed: 1, terrain_rev: 0, - calendar: CalendarSettings::default(), + calendar: config.calendar.clone(), + config, }; w.home = w .find_land(GRASS) - .unwrap_or_else(|| w.find_land(SAND).unwrap_or((W / 2, H / 2))); - for i in 0..W * H { + .unwrap_or_else(|| w.find_land(SAND).unwrap_or((width / 2, height / 2))); + for i in 0..width * height { let r = w.rng.f(); w.res[i] = match w.tiles[i] { - FOREST if r < 0.6 => (TREE, 5), - GRASS if r < 0.04 => (BERRY, 4), - HILLS if r < 0.15 => (STONE, 6), + 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 (hx, hy) = w.home; - w.res[hy * W + hx] = (0, 0); - for i in 0..9 { + w.res[hy * width + hx] = (0, 0); + for i in 0..w.config.initial_population { w.spawn_person(); - let stage = if i < 3 { + let stage = if i < w.config.initial_young_adults { LifeStage::YoungAdult } else { LifeStage::Adult @@ -606,25 +1089,25 @@ impl World { person.stage_duration_days = duration_days; person.age_ticks = age_days * w.calendar.ticks_per_day; } - for _ in 0..60 { + for _ in 0..w.config.initial_rabbits { if let Some(p) = w.random_land() { w.rabbits.push(Animal { actor_id: 0, x: p.0, y: p.1, - energy: 70.0, + energy: w.config.initial_animal_energy, dead: false, }); } } - for _ in 0..4 { + for _ in 0..w.config.initial_wolves { if let Some(p) = w.random_land() { - if cheb(p, w.home) > 15 { + if cheb(p, w.home) > w.config.wolf_min_home_distance { w.wolves.push(Animal { actor_id: 0, x: p.0, y: p.1, - energy: 70.0, + energy: w.config.initial_animal_energy, dead: false, }); } @@ -746,11 +1229,11 @@ impl World { } fn find_land(&self, kind: u8) -> Option<(usize, usize)> { - let c = (W / 2, H / 2); + let c = (self.width / 2, self.height / 2); let mut best: Option<(usize, usize)> = None; - for y in 0..H { - for x in 0..W { - if self.tiles[y * W + x] == kind + for y in 0..self.height { + for x in 0..self.width { + if self.tiles[y * self.width + x] == kind && best.is_none_or(|b| cheb((x, y), c) < cheb(b, c)) { best = Some((x, y)); @@ -761,8 +1244,8 @@ impl World { } fn random_land(&mut self) -> Option<(usize, usize)> { - for _ in 0..50 { - let (x, y) = (self.rng.below(W), self.rng.below(H)); + for _ in 0..self.config.random_land_attempts { + let (x, y) = (self.rng.below(self.width), self.rng.below(self.height)); if self.walkable(x as i32, y as i32) { return Some((x, y)); } @@ -781,7 +1264,7 @@ impl World { id: self.next_id, x, y, - hunger: 10.0, + hunger: self.config.initial_person_hunger, food: 0, wood: 0, stone: 0, @@ -821,7 +1304,7 @@ impl World { self.tick / self.calendar.ticks_per_day + 1, s )); - if self.log.len() > 8 { + if self.log.len() > self.config.log_entry_limit { self.log.remove(0); } } @@ -830,15 +1313,15 @@ impl World { let ticks_per_day = self.calendar.ticks_per_day; let phase = (self.tick % ticks_per_day) as f32 / ticks_per_day as f32; let v = -(phase * std::f32::consts::TAU).cos(); - (0.5 + v * 0.8).clamp(0.0, 1.0) + (self.config.daylight_base + v * self.config.daylight_amplitude).clamp(0.0, 1.0) } fn walkable(&self, x: i32, y: i32) -> bool { - if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { + if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 { return false; } matches!( - self.tiles[y as usize * W + x as usize], + self.tiles[y as usize * self.width + x as usize], SAND | GRASS | FOREST | HILLS ) } @@ -853,8 +1336,13 @@ impl World { } fn wander(&mut self, x: usize, y: usize) -> (usize, usize) { - for _ in 0..4 { - let (a, b) = (self.rng.below(3) as i32 - 1, self.rng.below(3) as i32 - 1); + let radius = self.config.wander_radius as i32; + let diameter = self.config.wander_radius * 2 + 1; + for _ in 0..self.config.wander_attempts { + let (a, b) = ( + self.rng.below(diameter) as i32 - radius, + self.rng.below(diameter) as i32 - radius, + ); if (a, b) != (0, 0) && self.walkable(x as i32 + a, y as i32 + b) { return ((x as i32 + a) as usize, (y as i32 + b) as usize); } @@ -867,11 +1355,11 @@ impl World { return from; } - let start = from.1 * W + from.0; - let target = to.1 * W + to.0; + let start = from.1 * self.width + from.0; + let target = to.1 * self.width + to.0; let mut frontier = BinaryHeap::from([Reverse((cheb(from, to), start))]); - let mut costs = vec![usize::MAX; W * H]; - let mut previous = vec![usize::MAX; W * H]; + let mut costs = vec![usize::MAX; self.width * self.height]; + let mut previous = vec![usize::MAX; self.width * self.height]; costs[start] = 0; previous[start] = start; @@ -881,10 +1369,10 @@ impl World { while previous[next] != start { next = previous[next]; } - return (next % W, next / W); + return (next % self.width, next / self.width); } - let position = (current % W, current / W); + let position = (current % self.width, current / self.width); for dy in -1..=1 { for dx in -1..=1 { if (dx, dy) == (0, 0) { @@ -895,7 +1383,7 @@ impl World { if !self.walkable(x, y) { continue; } - let neighbor = y as usize * W + x as usize; + let neighbor = y as usize * self.width + x as usize; let cost = costs[current] + 1; if cost >= costs[neighbor] { continue; @@ -915,11 +1403,17 @@ impl World { 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 + r).min(W - 1)); - let (y0, y1) = (from.1.saturating_sub(r), (from.1 + r).min(H - 1)); + 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 * W + x].0 == kind + 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)); @@ -945,7 +1439,7 @@ impl World { self.tick += 1; self.regrow(); self.advance_people(); - if self.tick % 2 == 0 { + if self.tick.is_multiple_of(self.config.entity_update_interval) { for i in 0..self.people.len() { self.tick_person(i); } @@ -1027,16 +1521,22 @@ impl World { } fn regrow(&mut self) { - for _ in 0..20 { - let i = self.rng.below(W * H); + for _ in 0..self.config.resource_regrow_attempts { + let i = self.rng.below(self.width * self.height); if self.res[i].0 != 0 { continue; } let p = self.rng.f(); match self.tiles[i] { - FOREST if p < 0.02 => self.res[i] = (TREE, 5), - GRASS if p < 0.02 => self.res[i] = (BERRY, 4), - HILLS if p < 0.004 => self.res[i] = (STONE, 6), + 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) + } _ => {} } } @@ -1051,26 +1551,31 @@ impl World { if self.tick.is_multiple_of(ticks_per_week) { self.have_children(ticks_per_week); } - if self.tick % 50 == 0 { - if self.rabbits.len() < 15 { + if self + .tick + .is_multiple_of(self.config.animal_population_interval) + { + if self.rabbits.len() < self.config.rabbit_spawn_cap { if let Some(p) = self.random_land() { self.rabbits.push(Animal { actor_id: 0, x: p.0, y: p.1, - energy: 70.0, + energy: self.config.initial_animal_energy, dead: false, }); } } - if self.wolves.len() < 2 && self.rabbits.len() > 40 { + if self.wolves.len() < self.config.wolf_spawn_cap + && self.rabbits.len() > self.config.wolf_spawn_rabbit_threshold + { if let Some(p) = self.random_land() { - if cheb(p, self.home) > 15 { + if cheb(p, self.home) > self.config.wolf_min_home_distance { self.wolves.push(Animal { actor_id: 0, x: p.0, y: p.1, - energy: 70.0, + energy: self.config.initial_animal_energy, dead: false, }); } @@ -1099,7 +1604,7 @@ impl World { .filter_map(|index| { let distance = cheb(first_position, (self.people[index].x, self.people[index].y)); - (distance <= 12).then_some((index, distance)) + (distance <= self.config.partnership_distance).then_some((index, distance)) }) .min_by_key(|(_, distance)| *distance) .map(|(index, _)| index); @@ -1126,7 +1631,9 @@ impl World { } fn have_children(&mut self, birth_interval_ticks: u64) { - if self.food < 10 || self.people.len() >= self.population_capacity() { + if self.food < self.config.birth_food_threshold + || self.people.len() >= self.population_capacity() + { return; } let partnerships: Vec<_> = self @@ -1142,7 +1649,9 @@ impl World { }) .collect(); for (partnership_id, first_id, second_id, last_birth_tick) in partnerships { - if self.food < 10 || self.people.len() >= self.population_capacity() { + if self.food < self.config.birth_food_threshold + || self.people.len() >= self.population_capacity() + { break; } if self.tick.saturating_sub(last_birth_tick) < birth_interval_ticks { @@ -1168,7 +1677,7 @@ impl World { continue; } - self.food -= 6; + self.food -= self.config.birth_food_cost; self.spawn_person_at(self.home); if let Some(partnership) = self .partnerships @@ -1184,7 +1693,7 @@ impl World { } fn population_capacity(&self) -> usize { - BASE_POPULATION_CAPACITY + self.houses.len() * 3 + self.config.population_capacity + self.houses.len() * self.config.house_capacity } fn decide(&mut self, func: &str, c: Ctx) -> String { @@ -1202,11 +1711,15 @@ impl World { if p.dead { return; } - let night = self.daylight() < 0.2; + let night = self.daylight() < self.config.daylight_night_threshold; let pos = (p.x, p.y); let home = self.home; - let at_home = cheb(pos, home) <= 1; - p.hunger += if night && at_home { 0.01 } else { 0.02 }; + let at_home = cheb(pos, home) <= self.config.home_radius; + p.hunger += if night && at_home { + self.config.hunger_night_at_home + } else { + self.config.hunger_other + }; if at_home { self.food += p.food; self.wood += p.wood; @@ -1214,23 +1727,27 @@ impl World { p.food = 0; p.wood = 0; p.stone = 0; - if p.hunger > 40.0 && self.food > 0 { - self.food -= 1; - p.hunger = (p.hunger - 25.0).max(0.0); + if p.hunger > self.config.hunger_eat_home_threshold + && self.food >= self.config.food_per_meal + { + self.food -= self.config.food_per_meal; + p.hunger = (p.hunger - self.config.hunger_meal_reduction).max(0.0); } self.craft_and_build(); } - if p.hunger > 50.0 && p.food > 0 { - p.food -= 1; - p.hunger = (p.hunger - 25.0).max(0.0); + if p.hunger > self.config.hunger_eat_carried_threshold + && p.food >= self.config.food_per_meal + { + p.food -= self.config.food_per_meal; + p.hunger = (p.hunger - self.config.hunger_meal_reduction).max(0.0); } - if p.hunger >= 100.0 { + if p.hunger >= self.config.hunger_starvation_threshold { p.dead = true; self.say(format!("Person #{} starved", p.id)); self.people[i] = p; return; } - if p.hunger > 40.0 && self.food > 0 && !at_home { + if p.hunger > self.config.hunger_return_threshold && self.food > 0 && !at_home { (p.x, p.y) = self.move_toward(pos, home); p.action = "returning for food".to_string(); self.people[i] = p; @@ -1245,10 +1762,10 @@ impl World { return; } - let berry = self.nearest_res(pos, BERRY, 25); - let tree = self.nearest_res(pos, TREE, 25); - let stone = self.nearest_res(pos, STONE, 25); - let rabbit = Self::nearest_animal(&self.rabbits, pos, 25); + 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(&self.rabbits, pos, self.config.resource_search_radius); let c = Ctx { night, at_home, @@ -1268,9 +1785,9 @@ impl World { let choice = self.decide("person", c); let yield_n: u16 = if self.tools >= self.people.len() as u32 { - 2 + self.config.person_tooled_gather_yield } else { - 1 + self.config.person_gather_yield }; let gather = match choice.as_str() { "forage" => Some((BERRY, berry, "foraging")), @@ -1294,10 +1811,10 @@ impl World { ("hunt", _, Some(r)) => { p.action = "hunting".to_string(); let t = (self.rabbits[r].x, self.rabbits[r].y); - if cheb(pos, t) <= 1 { - if self.rng.chance(0.5) { + if cheb(pos, t) <= self.config.attack_range { + if self.rng.chance(self.config.person_hunt_success_chance) { self.rabbits[r].dead = true; - p.food += 3; + p.food += self.config.person_hunt_food_yield; } } else { (p.x, p.y) = self.move_toward(pos, t); @@ -1305,7 +1822,7 @@ impl World { } (_, Some((kind, Some(t), label)), _) => { p.action = label.to_string(); - let idx = pos.1 * W + pos.0; + let idx = pos.1 * self.width + pos.0; if pos == t && self.res[idx].0 == kind { let take = yield_n.min(self.res[idx].1); self.res[idx].1 -= take; @@ -1330,21 +1847,31 @@ impl World { } fn craft_and_build(&mut self) { - if self.wood >= 3 && self.stone >= 1 && self.tools < self.people.len() as u32 { - self.wood -= 3; - self.stone -= 1; + if self.wood >= self.config.tool_wood_cost + && self.stone >= self.config.tool_stone_cost + && self.tools < self.people.len() as u32 + { + self.wood -= self.config.tool_wood_cost; + self.stone -= self.config.tool_stone_cost; self.tools += 1; } - if self.wood >= 12 && self.stone >= 4 && self.houses.len() < 10 { + 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 x = (hx as i32 + self.rng.below(13) as i32 - 6).clamp(0, W as i32 - 1); - let y = (hy as i32 + self.rng.below(13) as i32 - 6).clamp(0, H as i32 - 1); + let radius = self.config.house_placement_radius; + let diameter = radius * 2 + 1; + let x = (hx as i32 + self.rng.below(diameter) as i32 - radius as i32) + .clamp(0, self.width as i32 - 1); + let y = (hy as i32 + self.rng.below(diameter) as i32 - radius as i32) + .clamp(0, self.height as i32 - 1); if self.walkable(x, y) - && self.res[y as usize * W + x as usize].0 == 0 + && self.res[y as usize * self.width + x as usize].0 == 0 && !self.houses.contains(&(x as usize, y as usize)) { - self.wood -= 12; - self.stone -= 4; + self.wood -= self.config.house_wood_cost; + self.stone -= self.config.house_stone_cost; self.houses.push((x as usize, y as usize)); self.say("A new house was built".into()); } @@ -1353,12 +1880,13 @@ impl World { fn tick_rabbit(&mut self, i: usize) { let mut a = self.rabbits[i].clone(); - a.energy -= 0.05; - if matches!(self.tiles[a.y * W + a.x], GRASS | FOREST) { - a.energy = (a.energy + 0.2).min(100.0); + a.energy -= self.config.rabbit_energy_drain; + if matches!(self.tiles[a.y * self.width + a.x], GRASS | FOREST) { + a.energy = + (a.energy + self.config.rabbit_energy_regen).min(self.config.rabbit_energy_max); } let pos = (a.x, a.y); - let wolf = Self::nearest_animal(&self.wolves, pos, 4); + let wolf = Self::nearest_animal(&self.wolves, pos, self.config.rabbit_wolf_detection_range); let c = Ctx { wolf_near: wolf.is_some(), roll: self.rng.f() as f64, @@ -1373,17 +1901,20 @@ impl World { ("wander", _) => (a.x, a.y) = self.wander(a.x, a.y), _ => {} } - if a.energy > 80.0 && self.rabbits.len() < 120 && self.rng.chance(0.01) { - a.energy -= 30.0; + if a.energy > self.config.rabbit_breed_energy + && self.rabbits.len() < self.config.rabbit_max_population + && self.rng.chance(self.config.rabbit_breed_chance) + { + a.energy -= self.config.rabbit_breed_energy_cost; self.rabbits.push(Animal { actor_id: 0, x: a.x, y: a.y, - energy: 50.0, + energy: self.config.rabbit_newborn_energy, dead: false, }); } - if a.energy <= 0.0 { + if a.energy <= self.config.animal_energy_death_threshold { a.dead = true; } self.rabbits[i] = a; @@ -1391,9 +1922,9 @@ impl World { fn tick_wolf(&mut self, i: usize) { let mut a = self.wolves[i].clone(); - a.energy -= 0.15; + a.energy -= self.config.wolf_energy_drain; let pos = (a.x, a.y); - let prey = Self::nearest_animal(&self.rabbits, pos, 10); + let prey = Self::nearest_animal(&self.rabbits, pos, self.config.wolf_prey_range); let c = Ctx { prey_near: prey.is_some(), ..Default::default() @@ -1401,10 +1932,11 @@ impl World { match (self.decide("wolf", c).as_str(), prey) { ("hunt", Some(r)) => { let t = (self.rabbits[r].x, self.rabbits[r].y); - if cheb(pos, t) <= 1 { - if self.rng.chance(0.4) { + if cheb(pos, t) <= self.config.attack_range { + if self.rng.chance(self.config.wolf_hunt_success_chance) { self.rabbits[r].dead = true; - a.energy = (a.energy + 50.0).min(100.0); + a.energy = (a.energy + self.config.wolf_hunt_energy_gain) + .min(self.config.wolf_energy_max); } } else { (a.x, a.y) = self.move_toward(pos, t); @@ -1412,41 +1944,42 @@ impl World { } _ => (a.x, a.y) = self.wander(a.x, a.y), } - if a.energy > 90.0 && self.wolves.len() < 10 && self.rng.chance(0.02) { - a.energy -= 40.0; + if a.energy > self.config.wolf_breed_energy + && self.wolves.len() < self.config.wolf_max_population + && self.rng.chance(self.config.wolf_breed_chance) + { + a.energy -= self.config.wolf_breed_energy_cost; self.wolves.push(Animal { actor_id: 0, x: a.x, y: a.y, - energy: 50.0, + energy: self.config.wolf_newborn_energy, dead: false, }); } - if a.energy <= 0.0 { + if a.energy <= self.config.animal_energy_death_threshold { a.dead = true; } self.wolves[i] = a; } + /// 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 vm = load_script(src)?; - self.vm = vm; - self.script = src.to_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(seed); + let mut world = Self::new_with_script(seed, self.script.clone()); world.speed = self.speed; world.paused = self.paused; world.calendar = self.calendar.clone(); world.calendar.normalize(); - world.tick = world.calendar.ticks_per_day / 4; - if self.script != DEFAULT_SCRIPT { - world - .set_script(&self.script) - .expect("saved behavior script failed to reload"); - } + world.tick = (world.calendar.ticks_per_day / world.config.starting_tick_fraction).max(1); world } @@ -1466,6 +1999,8 @@ impl World { 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: self.people.clone(), @@ -1481,7 +2016,6 @@ impl World { tick: self.tick, next_id: self.next_id, log: self.log.clone(), - script: self.script.clone(), paused: self.paused, speed: self.speed, terrain_rev: self.terrain_rev, @@ -1492,10 +2026,22 @@ impl World { } pub fn script(&self) -> &str { - &self.script + self.script.source() } + #[cfg(test)] pub fn from_snapshot(snapshot: &str, fallback_seed: u64) -> Result { + Self::from_snapshot_with_script(snapshot, fallback_seed, default_script()) + } + + /// Restores world state from a snapshot; `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, + ) -> Result { + let config = script.config.clone(); let snapshot_value: serde_json::Value = serde_json::from_str(snapshot).map_err(|e| e.to_string())?; let legacy_people = snapshot_value @@ -1509,6 +2055,14 @@ impl World { .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 { @@ -1517,12 +2071,17 @@ impl World { saved.version )); } - if saved.tiles.len() != W * H || saved.res.len() != W * H { + 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 vm = load_script(&saved.script)?; let mut world = Self { seed: saved.seed.unwrap_or(fallback_seed), + width: saved.width, + height: saved.height, tiles: saved.tiles, res: saved.res, people: saved.people, @@ -1544,12 +2103,13 @@ impl World { tick: saved.tick, next_id: saved.next_id, log: saved.log, - script: saved.script, - vm, + vm: script.vm(), + script, paused: saved.paused, speed: saved.speed, terrain_rev: saved.terrain_rev, calendar: saved.calendar, + config, }; world.calendar.normalize(); if legacy_people { @@ -1562,7 +2122,7 @@ impl World { fn initialize_legacy_people(&mut self) { for index in 0..self.people.len() { - let stage = if index < 3 { + let stage = if index < self.config.initial_young_adults { LifeStage::YoungAdult } else { LifeStage::Adult @@ -1593,14 +2153,17 @@ impl World { } } - fn rect(t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> { + fn rect(&self, t: &[&str], i: usize) -> Option<(usize, usize, usize, usize)> { let n: Vec = (i..i + 4).filter_map(|k| t.get(k)?.parse().ok()).collect(); if n.len() != 4 { return None; } let c = |v: i64, max: usize| v.clamp(0, max as i64 - 1) as usize; - let (x0, x1) = (c(n[0].min(n[2]), W), c(n[0].max(n[2]), W)); - let (y0, y1) = (c(n[1].min(n[3]), H), c(n[1].max(n[3]), H)); + 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)) } @@ -1645,12 +2208,12 @@ impl World { } Some("speed") => { if let Some(n) = num(1) { - self.speed = n.clamp(1, 8) as u32; + self.speed = n.clamp(1, self.config.max_simulation_speed as i64) as u32; } } Some("stock") => { if let (Some(name), Some(v)) = (t.get(1), num(2)) { - let v = v.clamp(0, 9999) as u32; + let v = v.clamp(0, self.config.max_stock_edit as i64) as u32; match *name { "food" => self.food = v, "wood" => self.wood = v, @@ -1663,9 +2226,9 @@ impl World { Some("person") => { if let (Some(id), Some(field), Some(v)) = (num(1), t.get(2), num(3)) { if let Some(p) = self.people.iter_mut().find(|p| p.id as i64 == id) { - let u = v.clamp(0, 99) as u32; + let u = v.clamp(0, self.config.max_person_inventory_edit as i64) as u32; match *field { - "hunger" => p.hunger = v.clamp(0, 99) as f32, + "hunger" => p.hunger = v.clamp(0, self.config.max_hunger_edit) as f32, "food" => p.food = u, "wood" => p.wood = u, "stone" => p.stone = u, @@ -1675,8 +2238,8 @@ impl World { } } Some("energy") => { - if let (Some((x0, y0, x1, y1)), Some(v)) = (Self::rect(&t, 1), num(5)) { - let v = v.clamp(1, 100) as f32; + if let (Some((x0, y0, x1, y1)), Some(v)) = (self.rect(&t, 1), num(5)) { + let v = v.clamp(1, self.config.max_animal_energy_edit as i64) as f32; let inside = |a: &Animal| a.x >= x0 && a.x <= x1 && a.y >= y0 && a.y <= y1; for a in self.rabbits.iter_mut().chain(self.wolves.iter_mut()) { if inside(a) { @@ -1686,7 +2249,7 @@ impl World { } } Some("terrain") => { - if let (Some((x0, y0, x1, y1)), Some(kind)) = (Self::rect(&t, 1), num(5)) { + if let (Some((x0, y0, x1, y1)), Some(kind)) = (self.rect(&t, 1), num(5)) { if (0..=6).contains(&kind) { self.paint_terrain(x0, y0, x1, y1, kind as u8); } @@ -1694,13 +2257,13 @@ impl World { } Some("res") => { if let (Some((x0, y0, x1, y1)), Some(kind), Some(amt)) = - (Self::rect(&t, 1), num(5), num(6)) + (self.rect(&t, 1), num(5), num(6)) { if (0..=3).contains(&kind) { - let amt = amt.clamp(1, 50) as u16; + let amt = amt.clamp(1, self.config.max_resource_edit as i64) as u16; for y in y0..=y1 { for x in x0..=x1 { - let i = y * W + x; + let i = y * self.width + x; let free = self.walkable(x as i32, y as i32) && (x, y) != self.home && !self.houses.contains(&(x, y)); @@ -1715,8 +2278,8 @@ impl World { } } Some("spawn") => { - if let (Some(what), Some((x0, y0, x1, y1))) = (t.get(1), Self::rect(&t, 2)) { - let mut left = 25; + if let (Some(what), Some((x0, y0, x1, y1))) = (t.get(1), self.rect(&t, 2)) { + let mut left = self.config.manual_spawn_limit; for y in y0..=y1 { for x in x0..=x1 { if left == 0 || !self.walkable(x as i32, y as i32) { @@ -1727,7 +2290,7 @@ impl World { actor_id: 0, x, y, - energy: 70.0, + energy: self.config.initial_animal_energy, dead: false, }; match *what { @@ -1745,12 +2308,12 @@ impl World { } } Some("house") => { - if let Some((x0, y0, x1, y1)) = Self::rect(&t, 1) { + if let Some((x0, y0, x1, y1)) = self.rect(&t, 1) { for y in y0..=y1 { for x in x0..=x1 { - if self.houses.len() < 30 + if self.houses.len() < self.config.max_houses && self.walkable(x as i32, y as i32) - && self.res[y * W + x].0 == 0 + && self.res[y * self.width + x].0 == 0 && (x, y) != self.home && !self.houses.contains(&(x, y)) { @@ -1761,7 +2324,7 @@ impl World { } } Some("remove") => { - if let (Some((x0, y0, x1, y1)), Some(what)) = (Self::rect(&t, 1), t.get(5)) { + if let (Some((x0, y0, x1, y1)), Some(what)) = (self.rect(&t, 1), t.get(5)) { let inside = |x: usize, y: usize| x >= x0 && x <= x1 && y >= y0 && y <= y1; let all = *what == "all"; if all || *what == "people" { @@ -1779,7 +2342,7 @@ impl World { if all || *what == "res" { for y in y0..=y1 { for x in x0..=x1 { - self.res[y * W + x] = (0, 0); + self.res[y * self.width + x] = (0, 0); } } } @@ -1798,16 +2361,16 @@ impl World { if !land && (x, y) == self.home { continue; } - self.tiles[y * W + x] = kind; + self.tiles[y * self.width + x] = kind; if !land { - self.res[y * W + x] = (0, 0); + self.res[y * self.width + x] = (0, 0); self.houses.retain(|h| *h != (x, y)); } } } if !land { let ok = |tiles: &[u8], x: usize, y: usize| { - matches!(tiles[y * W + x], SAND | GRASS | FOREST | HILLS) + matches!(tiles[y * self.width + x], SAND | GRASS | FOREST | HILLS) }; let tiles = self.tiles.clone(); self.rabbits.retain(|a| ok(&tiles, a.x, a.y)); @@ -1822,7 +2385,10 @@ impl World { pub fn terrain_json(&self) -> String { let tiles: String = self.tiles.iter().map(|t| (b'0' + t) as char).collect(); - format!("{{\"w\":{},\"h\":{},\"tiles\":\"{}\"}}", W, H, tiles) + format!( + "{{\"w\":{},\"h\":{},\"tiles\":\"{}\"}}", + self.width, self.height, tiles + ) } pub fn state_json(&self) -> String { @@ -1839,7 +2405,7 @@ impl World { 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},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],", + "{{\"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, @@ -1853,6 +2419,13 @@ impl World { (self.tick % self.calendar.ticks_per_day) as f32 / self.calendar.ticks_per_day as f32 * 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, @@ -1942,7 +2515,7 @@ impl World { s.push(','); } first = false; - let _ = write!(s, "[{},{},{},{}]", i % W, i / W, r.0, r.1); + let _ = write!(s, "[{},{},{},{}]", i % self.width, i / self.width, r.0, r.1); } } s.push_str("],\"log\":["); @@ -1971,7 +2544,7 @@ impl Drop for World { #[cfg(test)] mod tests { - use super::{DEFAULT_SCRIPT, DEFAULT_TICKS_PER_DAY, H, LifeStage, W, World, cheb}; + use super::{DEFAULT_SCRIPT, H, LifeStage, SimScript, W, World, cheb, default_config}; fn simulation_state(world: &World) -> serde_json::Value { let mut state: serde_json::Value = serde_json::from_str(&world.state_json()).unwrap(); @@ -2234,7 +2807,10 @@ mod tests { assert_eq!(reset.speed, 4); assert!(reset.paused); assert_eq!(reset.people.len(), 9); - assert_eq!(reset.tick, DEFAULT_TICKS_PER_DAY / 4); + assert_eq!( + reset.tick, + default_config().calendar.ticks_per_day / default_config().starting_tick_fraction + ); } #[test] @@ -2288,7 +2864,83 @@ mod tests { 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_TICKS_PER_DAY); + 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 = DEFAULT_SCRIPT + .replace("WIDTH = 96", "WIDTH = 48") + .replace("HEIGHT = 64", "HEIGHT = 32") + .replace("INITIAL_POPULATION = 9", "INITIAL_POPULATION = 6") + .replace("INITIAL_YOUNG_ADULTS = 3", "INITIAL_YOUNG_ADULTS = 2") + .replace("INITIAL_FOOD = 60", "INITIAL_FOOD = 125") + .replace("TICKS_PER_DAY = 1800", "TICKS_PER_DAY = 240"); + 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.people.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"], 8); + + let restored = + World::from_snapshot_with_script(&world.snapshot_json().unwrap(), 42, script).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 = DEFAULT_SCRIPT.replace("MAX_SIMULATION_SPEED = 8", "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, 8); + } + + #[test] + fn behaviours_read_script_constants() { + let mut world = World::new(42); + let all_stay = DEFAULT_SCRIPT.replace( + "RABBIT_BEHAVIOR_WANDER_CHANCE = 0.5", + "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"); + world.set_script(DEFAULT_SCRIPT).unwrap(); + assert_eq!(world.decide("rabbit", c), "wander"); + } + + #[test] + fn invalid_rune_configuration_is_rejected() { + let source = DEFAULT_SCRIPT + .replace("INITIAL_POPULATION = 9", "INITIAL_POPULATION = 3") + .replace("INITIAL_YOUNG_ADULTS = 3", "INITIAL_YOUNG_ADULTS = 4"); + assert!( + SimScript::load(&source) + .err() + .unwrap() + .contains("initial_young_adults cannot exceed initial_population") + ); + let missing = DEFAULT_SCRIPT.replace("pub const WIDTH = 96;", ""); + 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()); + } } diff --git a/ui/src/lib.rs b/ui/src/lib.rs index b821ff3..5b029d5 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -190,10 +190,19 @@ fn App() -> impl IntoView { } on:change=move |e| api::edit(format!("speed {}", event_target_value(&e))) > - - - - + {move || state.with(|s| { + let max = s.as_ref().map_or(8, |s| s.limits.speed); + let mut speeds = vec![1, 2, 4, 8, 16, 32] + .into_iter() + .filter(|speed| *speed <= max) + .collect::>(); + if !speeds.contains(&max) { + speeds.push(max); + } + speeds.into_iter().map(|speed| view! { + + }).collect_view() + })}
@@ -201,7 +210,7 @@ fn App() -> impl IntoView { spawn_local(async { let _ = api::post("/api/reset", String::new()).await; }); - } title="Restart with the current seed and nine people">"Reset" + } title="Restart with the current seed and configured population">"Reset"
- "behavior.rn" + "config.rn"