feat(sim)!: move simulation settings and behaviours into config.rn

Starting values and tuning constants are now `pub const` items in a
single Rune file, config.rn, alongside the `person`, `rabbit` and `wolf`
behaviour functions. The behaviours read the constants directly. At
startup the server loads the file from SIM_CONFIG (default: config.rn),
reads and checks every constant, and test-runs each behaviour, so a bad
or missing value stops startup with an error that names it.

- Map width and height can now be configured and are stored in
  snapshots.
- The UI's edit limits (max speed, stock, inventory, hunger, energy,
  resource amounts) come from the config.
- The in-browser script editor now edits config.rn. Applied edits update
  behaviours and tuning values immediately; map size, initial population
  and calendar defaults apply on reset.
- The Docker image includes config.rn, and Compose mounts the
  repository's copy read-only.
- Documents the config file and how it works in the README.

BREAKING CHANGE: src/behavior.rn has been removed; behaviours now live
in config.rn. The server will not start without a valid config file.
Snapshots no longer store the behaviour script: on restart, config.rn
from disk replaces any edits made in the browser editor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-11 20:42:29 +02:00
parent 118d44ff29
commit 9285df4d7b
10 changed files with 1204 additions and 327 deletions

View File

@ -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

View File

@ -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.

View File

@ -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:

206
config.rn Normal file
View File

@ -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" }
}

View File

@ -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" }
}

View File

@ -118,7 +118,7 @@
</div>
<div id="editorPanel">
<div id="editorBar">
<b>behavior.rn</b>
<b>config.rn</b>
<button id="applyScript">Apply (Ctrl+Enter)</button>
<button id="closeEditor">Close</button>
<label><input type="checkbox" id="vimToggle"> Vim</label>

View File

@ -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<Msg>,
}
struct SimStart {
seed: u64,
script: SimScript,
snapshot: Option<String>,
}
/// The Rune VM is not Send, so the world lives on this thread and publishes JSON snapshots.
fn run_sim(
seed: u64,
snapshot: Option<String>,
SimStart {
seed,
script: sim_script,
snapshot,
}: SimStart,
mut rx: mpsc::UnboundedReceiver<Msg>,
terrain: Arc<Mutex<String>>,
script: Arc<Mutex<String>>,
@ -54,9 +63,9 @@ fn run_sim(
snapshot_tx: watch::Sender<Option<String>>,
) {
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::<str>::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,

File diff suppressed because it is too large Load Diff

View File

@ -190,10 +190,19 @@ fn App() -> impl IntoView {
}
on:change=move |e| api::edit(format!("speed {}", event_target_value(&e)))
>
<option value="1">"1"</option>
<option value="2">"2"</option>
<option value="4">"4"</option>
<option value="8">"8"</option>
{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::<Vec<_>>();
if !speeds.contains(&max) {
speeds.push(max);
}
speeds.into_iter().map(|speed| view! {
<option value=speed.to_string()>{speed}</option>
}).collect_view()
})}
</select>
</div>
<div>
@ -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"</button>
} title="Restart with the current seed and configured population">"Reset"</button>
<button on:click=move |_| {
spawn_local(async {
let _ = api::post("/api/regenerate", String::new()).await;
@ -269,7 +278,13 @@ fn App() -> impl IntoView {
<input
type="number"
min="1"
max="100"
prop:max=move || state.with(|s| s.as_ref().map(|s| {
match tool.get().as_str() {
"energy" => s.limits.animal_energy.to_string(),
"spawn" => s.limits.spawn.to_string(),
_ => s.limits.resource.to_string(),
}
}).unwrap_or_else(|| "100".to_string()))
title="amount / energy"
prop:value=move || amount.get()
on:input=move |e| amount.set(event_target_value(&e))
@ -287,7 +302,7 @@ fn App() -> impl IntoView {
people.into_iter().map(|p| (seq, p)).collect::<Vec<_>>()
}
key=|(seq, p)| (*seq, p.0)
children=|(_, p)| view! { <PersonEditor p=p /> }
children=move |(_, p)| view! { <PersonEditor p=p state=state /> }
/>
<h3>"Stockpile"</h3>
@ -380,7 +395,7 @@ fn StageFertility(state: RwSignal<Option<State>>, stage: String) -> impl IntoVie
}
#[component]
fn PersonEditor(p: Person) -> impl IntoView {
fn PersonEditor(p: Person, state: RwSignal<Option<State>>) -> impl IntoView {
let id = p.0;
let fields = [
("hunger", p.4 as u32),
@ -401,7 +416,13 @@ fn PersonEditor(p: Person) -> impl IntoView {
<input
type="number"
min="0"
max="99"
prop:max=move || state.with(|s| s.as_ref().map(|s| {
if k == "hunger" {
s.limits.hunger.to_string()
} else {
s.limits.person_inventory.to_string()
}
}).unwrap_or_else(|| "99".to_string()))
prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e))
/>
@ -450,6 +471,10 @@ fn StockEditor(state: RwSignal<Option<State>>) -> impl IntoView {
<input
type="number"
min="0"
prop:max=move || state.with(|s| {
s.as_ref().map(|s| s.limits.stock.to_string())
.unwrap_or_else(|| "9999".to_string())
})
prop:value=move || s.get()
on:input=move |e| s.set(event_target_value(&e))
/>
@ -527,7 +552,7 @@ fn ScriptEditor() -> impl IntoView {
</div>
<div class="editor-panel" style:display=move || if open.get() { "flex" } else { "none" }>
<div class="editor-bar">
<b>"behavior.rn"</b>
<b>"config.rn"</b>
<button on:click=move |_| apply()>"Apply (Ctrl+Enter)"</button>
<button on:click=move |_| open.set(false)>"Close"</button>
<label>

View File

@ -31,6 +31,17 @@ pub struct ActorRuntimeStats {
pub live_actors: u64,
}
#[derive(Deserialize, Clone)]
pub struct Limits {
pub speed: u32,
pub stock: u32,
pub person_inventory: u32,
pub hunger: i64,
pub animal_energy: f32,
pub resource: u16,
pub spawn: usize,
}
#[derive(Deserialize, Clone)]
pub struct StageRange {
pub min_days: u32,
@ -150,6 +161,7 @@ pub struct State {
pub daylight: f32,
pub stock: Stock,
pub actor_stats: ActorRuntimeStats,
pub limits: Limits,
pub home: (usize, usize),
pub houses: Vec<(usize, usize)>,
pub people: Vec<Person>,
@ -231,10 +243,10 @@ pub fn tool_help(tool: &str) -> &'static str {
match tool {
"terrain" => "Drag a region to paint it.",
"res" => "Drag a region; amount sets units per tile.",
"spawn" => "Drag a region; one per walkable tile (max 25).",
"spawn" => "Drag a region to spawn entities (limited by config.rn).",
"remove" => "Drag a region to remove things in it.",
"house" => "Drag a region to add houses.",
"energy" => "Drag a region; amount sets animal energy (1-100).",
"energy" => "Drag a region; amount sets animal energy (limited by config.rn).",
_ => "Click or drag to inspect and edit.",
}
}