diff --git a/CHANGELOG.md b/CHANGELOG.md index 9072a5f..ecebd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,9 @@ Notable changes to this project are documented here. ### Added - Expose the Picard actor console over the `/api/actors/ws` WebSocket endpoint. + +### Changed + +- People, rabbits, wolves, and partnerships are Picard actors that own their state; the world only reaches them through messages. Actors are named `person-`, `rabbit-`, `wolf-`, and `partnership-` instead of `entity-`. +- Rabbits killed by hunters no longer move or breed later in the same tick. +- Update Picard to `054449c`. diff --git a/README.md b/README.md index b49616a..9b5f82d 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ A browser-based world simulation with a Rust server and a Leptos/Wasm UI. The server runs the simulation and exposes its state over HTTP and WebSockets. You can edit terrain and resources, manage people and animals, and customize agent behavior with the included Rune script. -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. +Every person, rabbit, wolf, and partnership is a Picard actor that owns its state and applies its own rules: people age, get hungry, eat, and starve; animals spend and gain energy, breed, and die; partnerships track when they last had a child. The world keeps only world-level state (terrain, resources, the shared stockpile, houses, and the calendar) and talks to entities exclusively through messages: each step it asks the actors for their state, decides movement and opportunities with the Rune behavior scripts, and sends the actors the outcome. Behavior decisions stay on the simulation thread because Rune's VM is not `Send`, and replies are processed in entity order so runs stay deterministic. 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 server enables Picard's console by default and exposes its JSON request/response protocol over `ws://127.0.0.1:7878/api/actors/ws`. Send one JSON request per text frame; each receives one response. For example, `{"id":1,"op":"actors"}` lists actors, and `{"id":2,"op":"inspect","path":"/user/entity-1"}` inspects one. Other operations include `messages`, `pause`, `resume`, `clear`, `stop`, `restart`, and `send`. The route dispatches console requests through the simulation thread to Picard's actor system. `cargo run --no-default-features` disables the endpoint; `--features console` enables it explicitly. +The server enables Picard's console by default and exposes its JSON request/response protocol over `ws://127.0.0.1:7878/api/actors/ws`. Send one JSON request per text frame; each receives one response. For example, `{"id":1,"op":"actors"}` lists actors, and `{"id":2,"op":"inspect","path":"/user/person-1"}` inspects one (actors are named `person-`, `rabbit-`, `wolf-`, and `partnership-`). Other operations include `messages`, `pause`, `resume`, `clear`, `stop`, `restart`, and `send`. The route dispatches console requests through the simulation thread to Picard's actor system. `cargo run --no-default-features` disables the endpoint; `--features console` enables it explicitly. 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. diff --git a/src/world.rs b/src/world.rs index 407fc9a..5b7f1eb 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,20 +1,18 @@ -use picard::actors::{ - Actor, ActorFactoryArgs, ActorRef, ActorRefFactory, ActorReference, ActorSystem, - Context as ActorContext, Sender, Tell, +mod actors; + +use actors::{ + Activity, AnimalActor, AnimalId, AnimalMsg, AnimalTurn, Area, BirthCost, Goods, Mailbox, Needs, + PartnershipActor, PartnershipMsg, PersonActor, PersonEdit, PersonMsg, ReplyBody, RequestId, }; +use picard::actors::{ActorRef, ActorRefFactory, ActorReference, ActorSystem, Sender, Tell}; use rune::runtime::RuntimeContext; use rune::termcolor::Buffer; use rune::{Any, Context, Diagnostics, Module, Source, Sources, Unit, Vm}; use serde::{Deserialize, Serialize}; use std::cmp::Reverse; -use std::collections::{BinaryHeap, HashMap, HashSet}; +use std::collections::{BTreeMap, BinaryHeap, HashSet}; use std::fmt::Write; -use std::sync::{ - Arc, OnceLock, - atomic::{AtomicU64, Ordering}, - mpsc::{Receiver, Sender as MpscSender, channel}, -}; -use std::time::Duration; +use std::sync::{Arc, OnceLock}; pub const W: usize = 96; pub const H: usize = 64; @@ -851,8 +849,6 @@ struct Ctx { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Person { - #[serde(skip)] - actor_id: u64, id: u32, x: usize, y: usize, @@ -872,6 +868,9 @@ pub struct Person { stage_duration_days: u32, #[serde(default)] partner_id: Option, + /// State of the person's own random stream, used for their life-stage durations. + #[serde(default)] + rng: u64, } fn default_stage_duration_days() -> u32 { @@ -882,24 +881,24 @@ impl Person { fn carry(&self) -> u32 { self.food + self.wood + self.stone } + + fn position(&self) -> (usize, usize) { + (self.x, self.y) + } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Animal { - #[serde(skip)] - actor_id: u64, x: usize, y: usize, energy: f32, dead: bool, } -#[derive(Clone, Debug, Deserialize, Serialize)] -enum EntityKind { - Person(Person), - Rabbit(Animal), - Wolf(Animal), - Partnership(Partnership), +impl Animal { + fn position(&self) -> (usize, usize) { + (self.x, self.y) + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -909,110 +908,50 @@ struct Partnership { second_person_id: u32, formed_tick: u64, last_birth_tick: u64, - #[serde(skip)] - actor_id: u64, } -#[derive(Clone, Debug, Deserialize, Serialize)] -struct EntityState { - id: u64, - kind: EntityKind, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Species { + Rabbit, + Wolf, } -impl EntityState { - fn restore_actor_id(&mut self) { - match &mut self.kind { - EntityKind::Person(person) => person.actor_id = self.id, - EntityKind::Rabbit(animal) | EntityKind::Wolf(animal) => animal.actor_id = self.id, - EntityKind::Partnership(partnership) => partnership.actor_id = self.id, +/// The entity state observed from the actors at the start of a step, kept up to date with +/// their replies while the step runs. It is a read model only; the actors own the state. +#[derive(Default)] +struct Census { + people: Vec, + rabbits: Vec<(AnimalId, Animal)>, + wolves: Vec<(AnimalId, Animal)>, +} + +impl Census { + fn animals_mut(&mut self, species: Species) -> &mut Vec<(AnimalId, Animal)> { + match species { + Species::Rabbit => &mut self.rabbits, + Species::Wolf => &mut self.wolves, } } } -#[derive(Clone, Debug, Deserialize, Serialize)] -enum EntityMessage { - Read, - Write(EntityState), -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -enum EntityReply { - State(EntityState), - Written, -} - -struct EntityActor { - state: EntityState, -} - -struct EntityReplyActor { - reply: MpscSender, -} - -impl ActorFactoryArgs for EntityActor { - fn create_args(state: EntityState) -> Self { - Self { state } - } -} - -impl Actor for EntityActor { - type Msg = EntityMessage; - - fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { - match msg { - EntityMessage::Read => { - sender - .expect("Picard entity read message did not include a reply actor") - .try_tell(EntityReply::State(self.state.clone()), None) - .expect("failed to send Picard entity read response"); - } - EntityMessage::Write(mut state) => { - state.restore_actor_id(); - self.state = state; - sender - .expect("Picard entity write message did not include a reply actor") - .try_tell(EntityReply::Written, None) - .expect("failed to send Picard entity write response"); - } - } - } -} - -impl ActorFactoryArgs> for EntityReplyActor { - fn create_args(reply: MpscSender) -> Self { - Self { reply } - } -} - -impl Actor for EntityReplyActor { - type Msg = EntityReply; - - fn recv(&mut self, _ctx: &ActorContext, mut msg: Self::Msg, _sender: Sender) { - if let EntityReply::State(state) = &mut msg { - state.restore_actor_id(); - } - self.reply - .send(msg) - .expect("Picard entity reply receiver was dropped"); - } -} - -static NEXT_ENTITY_REPLY_ACTOR_ID: AtomicU64 = AtomicU64::new(1); - +/// World-level state (terrain, resources, the shared stockpile, houses, calendar and the world +/// random stream) lives here. Every person, animal and partnership is a Picard actor that owns +/// its own state; the world only coordinates them, because the Rune VM that decides behaviours +/// cannot leave the simulation thread. pub struct World { seed: u64, width: usize, height: usize, tiles: Vec, res: Vec<(u8, u16)>, - people: Vec, - rabbits: Vec, - wolves: Vec, - partnerships: Vec, + person_actors: BTreeMap>, + rabbit_actors: BTreeMap>, + wolf_actors: BTreeMap>, + partnership_actors: BTreeMap>, + next_animal_id: AnimalId, next_partnership_id: u32, + mailbox: Mailbox, actor_system: ActorSystem, - entities: HashMap>, - next_actor_id: u64, houses: Vec<(usize, usize)>, home: (usize, usize), food: u32, @@ -1082,6 +1021,55 @@ fn cheb(a: (usize, usize), b: (usize, usize)) -> usize { a.0.abs_diff(b.0).max(a.1.abs_diff(b.1)) } +fn sample_stage_duration(calendar: &CalendarSettings, stage: LifeStage, rng: &mut Rng) -> u32 { + let duration = match stage { + LifeStage::Toddler => calendar.toddler, + LifeStage::Child => calendar.child, + LifeStage::Teen => calendar.teen, + LifeStage::YoungAdult => calendar.young_adult, + LifeStage::Adult => calendar.adult, + LifeStage::OlderAdult => calendar.older_adult, + LifeStage::Elderly => calendar.elderly, + LifeStage::Dead => StageDuration { + min_days: 1, + max_days: 1, + fertile: false, + }, + }; + let range = duration.max_days - duration.min_days + 1; + duration.min_days + + u32::try_from(rng.below(range as usize)).expect("sample is below a u32 range") +} + +/// Drops partnerships with a missing member and clears partner links without a partnership. +fn repair_partnerships(people: &mut [Person], partnerships: &mut Vec) { + let alive: HashSet = people.iter().map(|person| person.id).collect(); + partnerships.retain(|partnership| { + alive.contains(&partnership.first_person_id) + && alive.contains(&partnership.second_person_id) + }); + let partnered: HashSet<(u32, u32)> = partnerships + .iter() + .map(|partnership| (partnership.first_person_id, partnership.second_person_id)) + .flat_map(|(first, second)| [(first, second), (second, first)]) + .collect(); + for person in people { + if person + .partner_id + .is_some_and(|partner| !partnered.contains(&(person.id, partner))) + { + person.partner_id = None; + } + } +} + +/// Collects one reply per actor and sorts them by key, so the simulation stays deterministic +/// whatever order the actors answered in. +fn sorted_by_key(mut items: Vec<(K, T)>) -> Vec<(K, T)> { + items.sort_by(|a, b| a.0.cmp(&b.0)); + items +} + impl World { #[cfg(test)] pub fn new(seed: u64) -> World { @@ -1136,79 +1124,75 @@ impl World { fn populate(&mut self) { for i in 0..self.config.initial_population { - self.spawn_person(); + let mut person = self.new_person(self.home); let stage = if i < self.config.initial_young_adults { LifeStage::YoungAdult } else { LifeStage::Adult }; - let duration_days = self.sample_stage_duration(stage); - let prior_stages = match stage { - LifeStage::YoungAdult => { - [LifeStage::Toddler, LifeStage::Child, LifeStage::Teen].as_slice() - } - LifeStage::Adult => [ - LifeStage::Toddler, - LifeStage::Child, - LifeStage::Teen, - LifeStage::YoungAdult, - ] - .as_slice(), - _ => &[], - }; - let age_days: u64 = prior_stages - .iter() - .map(|&past_stage| u64::from(self.sample_stage_duration(past_stage))) - .sum(); - let person = self.people.last_mut().expect("just spawned person"); - person.stage = stage; - person.stage_duration_days = duration_days; - person.age_ticks = age_days * self.calendar.ticks_per_day; + self.make_grown_up(&mut person, stage); + self.spawn_person_actor(person); } for _ in 0..self.config.initial_rabbits { if let Some(p) = self.random_land() { - self.rabbits.push(Animal { - actor_id: 0, - x: p.0, - y: p.1, - energy: self.config.initial_animal_energy, - dead: false, - }); + let energy = self.config.initial_animal_energy; + self.spawn_animal(Species::Rabbit, p, energy); } } for _ in 0..self.config.initial_wolves { if let Some(p) = self.random_land() && cheb(p, self.home) > self.config.wolf_min_home_distance { - self.wolves.push(Animal { - actor_id: 0, - x: p.0, - y: p.1, - energy: self.config.initial_animal_energy, - dead: false, - }); + let energy = self.config.initial_animal_energy; + self.spawn_animal(Species::Wolf, p, energy); } } } - pub fn new_with_script(seed: u64, script: SimScript) -> World { + /// Gives `person` the age of someone who has lived through every stage before `stage`. + fn make_grown_up(&mut self, person: &mut Person, stage: LifeStage) { + let duration_days = self.sample_stage_duration(stage); + let prior_stages = match stage { + LifeStage::YoungAdult => { + [LifeStage::Toddler, LifeStage::Child, LifeStage::Teen].as_slice() + } + LifeStage::Adult => [ + LifeStage::Toddler, + LifeStage::Child, + LifeStage::Teen, + LifeStage::YoungAdult, + ] + .as_slice(), + _ => &[], + }; + let age_days: u64 = prior_stages + .iter() + .map(|&past_stage| u64::from(self.sample_stage_duration(past_stage))) + .sum(); + person.stage = stage; + person.stage_duration_days = duration_days; + person.stage_elapsed_ticks = 0; + person.age_ticks = age_days * self.calendar.ticks_per_day; + } + + fn empty(seed: u64, script: SimScript, tiles: Vec, res: Vec<(u8, u16)>) -> World { let config = script.config.clone(); let (width, height) = (config.width, config.height); - let tiles = Self::generate_terrain(seed, &config); - let mut w = World { + let actor_system = ActorSystem::new().expect("failed to start Picard actor system"); + World { seed, width, height, tiles, - res: vec![(0, 0); width * height], - people: vec![], - rabbits: vec![], - wolves: vec![], - partnerships: vec![], + res, + person_actors: BTreeMap::new(), + rabbit_actors: BTreeMap::new(), + wolf_actors: BTreeMap::new(), + partnership_actors: BTreeMap::new(), + next_animal_id: 1, next_partnership_id: 1, - actor_system: ActorSystem::new().expect("failed to start Picard actor system"), - entities: HashMap::new(), - next_actor_id: 1, + mailbox: Mailbox::new(&actor_system), + actor_system, houses: vec![], home: (width / 2, height / 2), food: config.initial_food, @@ -1226,7 +1210,14 @@ impl World { terrain_rev: 0, calendar: config.calendar.clone(), config, - }; + } + } + + pub fn new_with_script(seed: u64, script: SimScript) -> World { + let tiles = Self::generate_terrain(seed, &script.config); + let res = vec![(0, 0); tiles.len()]; + let mut w = Self::empty(seed, script, tiles, res); + let (width, height) = (w.width, w.height); w.home = w .find_land(GRASS) .unwrap_or_else(|| w.find_land(SAND).unwrap_or((width / 2, height / 2))); @@ -1246,179 +1237,169 @@ impl World { let (hx, hy) = w.home; w.res[hy * width + hx] = (0, 0); w.populate(); - w.sync_entities(); w } - fn create_reply_actor( - actor_system: &ActorSystem, - ) -> (ActorRef, Receiver) { - let (reply, receiver) = channel(); - let actor_id = NEXT_ENTITY_REPLY_ACTOR_ID.fetch_add(1, Ordering::Relaxed); - let actor = actor_system - .actor_of_args::(&format!("entity-reply-{actor_id}"), reply) - .expect("failed to create Picard entity reply actor"); - (actor, receiver) + fn spawn_person_actor(&mut self, person: Person) { + let id = person.id; + let actor = self + .actor_system + .actor_of_args::(&format!("person-{id}"), person) + .expect("failed to create Picard person actor"); + self.person_actors.insert(id, actor); } - fn read_entities( - actor_system: &ActorSystem, - actors: &HashMap>, - ) -> Vec { + fn animal_actors(&self, species: Species) -> &BTreeMap> { + match species { + Species::Rabbit => &self.rabbit_actors, + Species::Wolf => &self.wolf_actors, + } + } + + fn animal_actors_mut( + &mut self, + species: Species, + ) -> &mut BTreeMap> { + match species { + Species::Rabbit => &mut self.rabbit_actors, + Species::Wolf => &mut self.wolf_actors, + } + } + + fn spawn_animal_actor(&mut self, species: Species, animal: Animal) -> AnimalId { + let id = self.next_animal_id; + self.next_animal_id += 1; + let kind = match species { + Species::Rabbit => "rabbit", + Species::Wolf => "wolf", + }; + let actor = self + .actor_system + .actor_of_args::(&format!("{kind}-{id}"), (id, animal)) + .expect("failed to create Picard animal actor"); + self.animal_actors_mut(species).insert(id, actor); + id + } + + fn spawn_animal( + &mut self, + species: Species, + (x, y): (usize, usize), + energy: f32, + ) -> (AnimalId, Animal) { + let animal = Animal { + x, + y, + energy, + dead: false, + }; + (self.spawn_animal_actor(species, animal.clone()), animal) + } + + fn spawn_partnership_actor(&mut self, partnership: Partnership) { + let id = partnership.id; + let actor = self + .actor_system + .actor_of_args::(&format!("partnership-{id}"), partnership) + .expect("failed to create Picard partnership actor"); + self.partnership_actors.insert(id, actor); + } + + fn retire_person(&mut self, id: u32) { + if let Some(actor) = self.person_actors.remove(&id) { + actor.stop(); + } + } + + fn retire_animal(&mut self, species: Species, id: AnimalId) { + if let Some(actor) = self.animal_actors_mut(species).remove(&id) { + actor.stop(); + } + } + + fn retire_partnership(&mut self, id: u32) { + if let Some(actor) = self.partnership_actors.remove(&id) { + actor.stop(); + } + } + + /// Sends one message to every actor in `actors` and waits for all replies. + fn broadcast( + &self, + actors: &BTreeMap>, + message: impl Fn(RequestId) -> M, + ) -> Vec { if actors.is_empty() { return Vec::new(); } - let (reply_actor, replies) = Self::create_reply_actor(actor_system); + let request = self.mailbox.request(); for actor in actors.values() { - actor.tell(EntityMessage::Read, Some(reply_actor.clone().into())); + actor.tell(message(request), self.mailbox.reply_to()); } - let mut states = Vec::with_capacity(actors.len()); - for _ in 0..actors.len() { - match replies.recv_timeout(Duration::from_secs(5)) { - Ok(EntityReply::State(state)) => states.push(state), - Ok(EntityReply::Written) => { - reply_actor.stop(); - panic!("Picard entity actor returned an unexpected write response"); - } - Err(error) => { - reply_actor.stop(); - panic!("Picard entity actor did not respond to read: {error}"); - } - } - } - reply_actor.stop(); - states.sort_by_key(|state| state.id); - states + self.mailbox.collect(request, actors.len()) } - fn load_entities(&mut self) { - let states = Self::read_entities(&self.actor_system, &self.entities); - self.people.clear(); - self.rabbits.clear(); - self.wolves.clear(); - self.partnerships.clear(); - for state in states { - match state.kind { - EntityKind::Person(person) => self.people.push(person), - EntityKind::Rabbit(animal) => self.rabbits.push(animal), - EntityKind::Wolf(animal) => self.wolves.push(animal), - EntityKind::Partnership(partnership) => self.partnerships.push(partnership), - } - } - } - - fn sync_entities(&mut self) { - for person in &mut self.people { - if person.actor_id == 0 { - person.actor_id = self.next_actor_id; - self.next_actor_id += 1; - } - } - for animal in self.rabbits.iter_mut().chain(self.wolves.iter_mut()) { - if animal.actor_id == 0 { - animal.actor_id = self.next_actor_id; - self.next_actor_id += 1; - } - } - for partnership in &mut self.partnerships { - if partnership.actor_id == 0 { - partnership.actor_id = self.next_actor_id; - self.next_actor_id += 1; - } - } - - let states: Vec<_> = self - .people - .iter() - .cloned() - .map(|person| EntityState { - id: person.actor_id, - kind: EntityKind::Person(person), + fn observe_people(&self) -> Vec { + let people = self + .broadcast(&self.person_actors, PersonMsg::Observe) + .into_iter() + .map(|reply| match reply { + ReplyBody::Person(person) => (person.id, person), + other => panic!("person actor answered an observation with {other:?}"), }) - .chain(self.rabbits.iter().cloned().map(|animal| EntityState { - id: animal.actor_id, - kind: EntityKind::Rabbit(animal), - })) - .chain(self.wolves.iter().cloned().map(|animal| EntityState { - id: animal.actor_id, - kind: EntityKind::Wolf(animal), - })) - .chain( - self.partnerships - .iter() - .cloned() - .map(|partnership| EntityState { - id: partnership.actor_id, - kind: EntityKind::Partnership(partnership), - }), - ) .collect(); - let write_count = states - .iter() - .filter(|state| self.entities.contains_key(&state.id)) - .count(); - let (reply_actor, replies) = if write_count == 0 { - (None, None) - } else { - let (actor, replies) = Self::create_reply_actor(&self.actor_system); - (Some(actor), Some(replies)) - }; - let mut active = HashSet::new(); - for state in states { - active.insert(state.id); - if let Some(actor) = self.entities.get(&state.id) { - actor.tell( - EntityMessage::Write(state), - Some( - reply_actor - .as_ref() - .expect("missing Picard entity reply actor") - .clone() - .into(), - ), - ); - } else { - let id = state.id; - let name = format!("entity-{id}"); - let actor = self - .actor_system - .actor_of_args::(&name, state) - .expect("failed to create Picard entity actor"); - self.entities.insert(id, actor); - } + sorted_by_key(people).into_iter().map(|(_, p)| p).collect() + } + + fn observe_animals(&self, species: Species) -> Vec<(AnimalId, Animal)> { + let animals = self + .broadcast(self.animal_actors(species), AnimalMsg::Observe) + .into_iter() + .map(|reply| match reply { + ReplyBody::Animal(id, animal) => (id, animal), + other => panic!("animal actor answered an observation with {other:?}"), + }) + .collect(); + sorted_by_key(animals) + } + + fn observe_partnerships(&self) -> Vec { + let partnerships = self + .broadcast(&self.partnership_actors, PartnershipMsg::Observe) + .into_iter() + .map(|reply| match reply { + ReplyBody::Partnership(partnership) => (partnership.id, partnership), + other => panic!("partnership actor answered an observation with {other:?}"), + }) + .collect(); + sorted_by_key(partnerships) + .into_iter() + .map(|(_, p)| p) + .collect() + } + + fn census(&self) -> Census { + Census { + people: self.observe_people(), + rabbits: self.observe_animals(Species::Rabbit), + wolves: self.observe_animals(Species::Wolf), } - if let Some(replies) = replies { - for _ in 0..write_count { - match replies.recv_timeout(Duration::from_secs(5)) { - Ok(EntityReply::Written) => {} - Ok(EntityReply::State(_)) => { - reply_actor - .as_ref() - .expect("missing Picard entity reply actor") - .stop(); - panic!("Picard entity actor returned an unexpected read response"); - } - Err(error) => { - reply_actor - .as_ref() - .expect("missing Picard entity reply actor") - .stop(); - panic!("Picard entity actor did not respond to write: {error}"); - } - } - } + } + + fn tell_person(&self, id: u32, message: PersonMsg) { + if let Some(actor) = self.person_actors.get(&id) { + actor.tell(message, None); } - if let Some(reply_actor) = reply_actor { - reply_actor.stop(); - } - self.entities.retain(|id, actor| { - if active.contains(id) { - true - } else { - actor.stop(); - false - } - }); + } + + /// Sends a request to one actor and waits for its reply. + fn ask( + &self, + actor: &ActorRef, + message: impl FnOnce(RequestId) -> M, + ) -> ReplyBody { + self.mailbox + .ask(|request, sender: Sender| actor.tell(message(request), sender)) } fn find_land(&self, kind: u8) -> Option<(usize, usize)> { @@ -1446,14 +1427,9 @@ impl World { None } - fn spawn_person(&mut self) { - self.spawn_person_at(self.home); - } - - fn spawn_person_at(&mut self, (x, y): (usize, usize)) { + fn new_person(&mut self, (x, y): (usize, usize)) -> Person { let stage_duration_days = self.sample_stage_duration(LifeStage::Toddler); - self.people.push(Person { - actor_id: 0, + let person = Person { id: self.next_id, x, y, @@ -1468,32 +1444,24 @@ impl World { stage_elapsed_ticks: 0, stage_duration_days, partner_id: None, - }); + rng: self.rng.next() | 1, + }; self.next_id += 1; + person + } + + fn spawn_person_at(&mut self, position: (usize, usize)) -> Person { + let person = self.new_person(position); + self.spawn_person_actor(person.clone()); + person } fn sample_stage_duration(&mut self, stage: LifeStage) -> u32 { - let duration = match stage { - LifeStage::Toddler => self.calendar.toddler, - LifeStage::Child => self.calendar.child, - LifeStage::Teen => self.calendar.teen, - LifeStage::YoungAdult => self.calendar.young_adult, - LifeStage::Adult => self.calendar.adult, - LifeStage::OlderAdult => self.calendar.older_adult, - LifeStage::Elderly => self.calendar.elderly, - LifeStage::Dead => StageDuration { - min_days: 1, - max_days: 1, - fertile: false, - }, - }; - let range = duration.max_days - duration.min_days + 1; - duration.min_days - + u32::try_from(self.rng.below(range as usize)).expect("sample is below a u32 range") + sample_stage_duration(&self.calendar, stage, &mut self.rng) } fn population_u32(&self) -> u32 { - u32::try_from(self.people.len()).unwrap_or(u32::MAX) + u32::try_from(self.person_actors.len()).unwrap_or(u32::MAX) } fn say(&mut self, s: &str) { @@ -1634,10 +1602,14 @@ impl World { best } - fn nearest_animal(list: &[Animal], from: (usize, usize), r: usize) -> Option { + fn nearest_animal( + animals: &[(AnimalId, Animal)], + from: (usize, usize), + r: usize, + ) -> Option { let mut best: Option<(usize, usize)> = None; - for (i, a) in list.iter().enumerate() { - let d = cheb((a.x, a.y), from); + for (i, (_, a)) in animals.iter().enumerate() { + let d = cheb(a.position(), from); if !a.dead && d <= r && best.is_none_or(|(_, bd)| d < bd) { best = Some((i, d)); } @@ -1646,87 +1618,95 @@ impl World { } pub fn step(&mut self) { - self.load_entities(); self.tick += 1; self.regrow(); - self.advance_people(); + let mut census = Census { + people: self.advance_people(), + rabbits: self.observe_animals(Species::Rabbit), + wolves: self.observe_animals(Species::Wolf), + }; if self.tick.is_multiple_of(self.config.entity_update_interval) { - for i in 0..self.people.len() { - self.tick_person(i); - } - for i in 0..self.rabbits.len() { - self.tick_rabbit(i); - } + self.tick_people(&mut census); + self.tick_rabbits(&mut census); } else { - for i in 0..self.wolves.len() { - self.tick_wolf(i); - } + self.tick_wolves(&mut census); } - self.people.retain(|p| !p.dead); - self.rabbits.retain(|a| !a.dead); - self.wolves.retain(|a| !a.dead); - self.prune_partnerships(); - self.population(); - self.sync_entities(); + if self.retire_dead(&mut census) { + self.prune_partnerships(&mut census.people); + } + self.population(&mut census); } - fn advance_people(&mut self) { - let mut notices = Vec::new(); - for i in 0..self.people.len() { - if self.people[i].dead { - continue; - } - self.people[i].age_ticks = self.people[i].age_ticks.saturating_add(1); - self.people[i].stage_elapsed_ticks = - self.people[i].stage_elapsed_ticks.saturating_add(1); - let duration_ticks = - u64::from(self.people[i].stage_duration_days) * self.calendar.ticks_per_day; - if self.people[i].stage_elapsed_ticks < duration_ticks { - continue; - } - - let person_id = self.people[i].id; - if let Some(next) = self.people[i].stage.next() { - self.people[i].stage = next; - self.people[i].stage_elapsed_ticks = 0; - if next == LifeStage::Dead { - self.people[i].dead = true; - notices.push(format!("Person #{person_id} died of old age")); - } else { - self.people[i].stage_duration_days = self.sample_stage_duration(next); - notices.push(format!( - "Person #{person_id} became a {}", - next.key().replace('_', " ") - )); - } - } else { - self.people[i].dead = true; - notices.push(format!("Person #{person_id} died of old age")); - } - } - for notice in notices { - self.say(¬ice); - } - } - - fn prune_partnerships(&mut self) { - let alive: HashSet = self.people.iter().map(|person| person.id).collect(); - self.partnerships.retain(|partnership| { - alive.contains(&partnership.first_person_id) - && alive.contains(&partnership.second_person_id) + /// Ages every person by one tick and returns them, logging stage changes and deaths. + fn advance_people(&mut self) -> Vec { + let calendar = self.calendar.clone(); + let replies = self.broadcast(&self.person_actors, |request| { + PersonMsg::Age(request, calendar.clone()) }); - let partnered: HashSet<(u32, u32)> = self - .partnerships - .iter() - .map(|partnership| (partnership.first_person_id, partnership.second_person_id)) - .flat_map(|(first, second)| [(first, second), (second, first)]) + let aged = replies + .into_iter() + .map(|reply| match reply { + ReplyBody::Aged(person, stage) => (person.id, (person, stage)), + other => panic!("person actor answered ageing with {other:?}"), + }) .collect(); - for person in &mut self.people { - if person - .partner_id - .is_some_and(|partner| !partnered.contains(&(person.id, partner))) - { - person.partner_id = None; + let mut people = Vec::with_capacity(self.person_actors.len()); + for (id, (person, stage)) in sorted_by_key(aged) { + match stage { + Some(LifeStage::Dead) => self.say(&format!("Person #{id} died of old age")), + Some(stage) => self.say(&format!( + "Person #{id} became a {}", + stage.key().replace('_', " ") + )), + None => {} + } + people.push(person); + } + people + } + + /// Stops the actors of dead entities; returns whether any person died. + fn retire_dead(&mut self, census: &mut Census) -> bool { + let dead_people: Vec = census + .people + .iter() + .filter(|person| person.dead) + .map(|person| person.id) + .collect(); + for &id in &dead_people { + self.retire_person(id); + } + census.people.retain(|person| !person.dead); + for species in [Species::Rabbit, Species::Wolf] { + let dead: Vec = census + .animals_mut(species) + .iter() + .filter(|(_, animal)| animal.dead) + .map(|(id, _)| *id) + .collect(); + for id in dead { + self.retire_animal(species, id); + } + census + .animals_mut(species) + .retain(|(_, animal)| !animal.dead); + } + !dead_people.is_empty() + } + + /// Dissolves partnerships that lost a member and tells their survivors. + fn prune_partnerships(&mut self, people: &mut [Person]) { + let mut partnerships = self.observe_partnerships(); + let before: HashSet = partnerships.iter().map(|p| p.id).collect(); + let partners_before: Vec> = people.iter().map(|p| p.partner_id).collect(); + repair_partnerships(people, &mut partnerships); + let kept: HashSet = partnerships.iter().map(|p| p.id).collect(); + for id in before.difference(&kept) { + self.retire_partnership(*id); + } + for (person, partner_before) in people.iter().zip(partners_before) { + if person.partner_id != partner_before { + self.tell_person(person.id, PersonMsg::SetPartner(person.partner_id)); } } } @@ -1753,66 +1733,56 @@ impl World { } } - fn population(&mut self) { + fn population(&mut self, census: &mut Census) { let ticks_per_day = self.calendar.ticks_per_day; if self.tick.is_multiple_of(ticks_per_day) { - self.find_partners(); + self.find_partners(&mut census.people); } let ticks_per_week = ticks_per_day * u64::from(self.calendar.days_per_week); if self.tick.is_multiple_of(ticks_per_week) { - self.have_children(ticks_per_week); + self.have_children(ticks_per_week, census); } if self .tick .is_multiple_of(self.config.animal_population_interval) { - if self.rabbits.len() < self.config.rabbit_spawn_cap + let energy = self.config.initial_animal_energy; + if census.rabbits.len() < self.config.rabbit_spawn_cap && let Some(p) = self.random_land() { - self.rabbits.push(Animal { - actor_id: 0, - x: p.0, - y: p.1, - energy: self.config.initial_animal_energy, - dead: false, - }); + let rabbit = self.spawn_animal(Species::Rabbit, p, energy); + census.rabbits.push(rabbit); } - if self.wolves.len() < self.config.wolf_spawn_cap - && self.rabbits.len() > self.config.wolf_spawn_rabbit_threshold + if census.wolves.len() < self.config.wolf_spawn_cap + && census.rabbits.len() > self.config.wolf_spawn_rabbit_threshold && let Some(p) = self.random_land() && cheb(p, self.home) > self.config.wolf_min_home_distance { - self.wolves.push(Animal { - actor_id: 0, - x: p.0, - y: p.1, - energy: self.config.initial_animal_energy, - dead: false, - }); + let wolf = self.spawn_animal(Species::Wolf, p, energy); + census.wolves.push(wolf); } } } - fn find_partners(&mut self) { + fn find_partners(&mut self, people: &mut [Person]) { let ticks_per_week = self.calendar.ticks_per_day * u64::from(self.calendar.days_per_week); - for first in 0..self.people.len() { - if self.people[first].partner_id.is_some() - || !self.people[first].stage.can_partner() - || self.people[first].dead + for first in 0..people.len() { + if people[first].partner_id.is_some() + || !people[first].stage.can_partner() + || people[first].dead { continue; } - let first_position = (self.people[first].x, self.people[first].y); - let second = (0..self.people.len()) + let first_position = people[first].position(); + let second = (0..people.len()) .filter(|&index| { index != first - && self.people[index].partner_id.is_none() - && self.people[index].stage.can_partner() - && !self.people[index].dead + && people[index].partner_id.is_none() + && people[index].stage.can_partner() + && !people[index].dead }) .filter_map(|index| { - let distance = - cheb(first_position, (self.people[index].x, self.people[index].y)); + let distance = cheb(first_position, people[index].position()); (distance <= self.config.partnership_distance).then_some((index, distance)) }) .min_by_key(|(_, distance)| *distance) @@ -1820,17 +1790,18 @@ impl World { let Some(second) = second else { continue; }; - let first_id = self.people[first].id; - let second_id = self.people[second].id; - self.people[first].partner_id = Some(second_id); - self.people[second].partner_id = Some(first_id); - self.partnerships.push(Partnership { + let first_id = people[first].id; + let second_id = people[second].id; + people[first].partner_id = Some(second_id); + people[second].partner_id = Some(first_id); + self.tell_person(first_id, PersonMsg::SetPartner(Some(second_id))); + self.tell_person(second_id, PersonMsg::SetPartner(Some(first_id))); + self.spawn_partnership_actor(Partnership { id: self.next_partnership_id, first_person_id: first_id, second_person_id: second_id, formed_tick: self.tick, last_birth_tick: self.tick / ticks_per_week * ticks_per_week, - actor_id: 0, }); self.next_partnership_id += 1; self.say(&format!( @@ -1839,61 +1810,54 @@ impl World { } } - fn have_children(&mut self, birth_interval_ticks: u64) { + fn have_children(&mut self, birth_interval_ticks: u64, census: &mut Census) { if self.food < self.config.birth_food_threshold - || self.people.len() >= self.population_capacity() + || census.people.len() >= self.population_capacity() { return; } - let partnerships: Vec<_> = self - .partnerships - .iter() - .map(|partnership| { - ( - partnership.id, - partnership.first_person_id, - partnership.second_person_id, - partnership.last_birth_tick, - ) + let tick = self.tick; + let replies = self.broadcast(&self.partnership_actors, |request| { + PartnershipMsg::BirthDue { + request, + tick, + interval: birth_interval_ticks, + } + }); + let due = replies + .into_iter() + .map(|reply| match reply { + ReplyBody::BirthDue(partnership, due) => (partnership.id, (partnership, due)), + other => panic!("partnership actor answered a birth check with {other:?}"), }) .collect(); - for (partnership_id, first_id, second_id, last_birth_tick) in partnerships { + for (partnership_id, (partnership, due)) in sorted_by_key(due) { if self.food < self.config.birth_food_threshold - || self.people.len() >= self.population_capacity() + || census.people.len() >= self.population_capacity() { break; } - if self.tick.saturating_sub(last_birth_tick) < birth_interval_ticks { + if !due { continue; } - let Some(first_index) = self.people.iter().position(|p| p.id == first_id) else { - continue; + let (first_id, second_id) = (partnership.first_person_id, partnership.second_person_id); + let fertile = |id: u32| { + census + .people + .iter() + .find(|person| person.id == id) + .and_then(|person| self.calendar.duration(person.stage.key())) + .is_some_and(|duration| duration.fertile) }; - let Some(second_index) = self.people.iter().position(|p| p.id == second_id) else { - continue; - }; - let first_stage = self.people[first_index].stage; - let second_stage = self.people[second_index].stage; - let can_have_children = self - .calendar - .duration(first_stage.key()) - .is_some_and(|duration| duration.fertile) - && self - .calendar - .duration(second_stage.key()) - .is_some_and(|duration| duration.fertile); - if !can_have_children { + if !(fertile(first_id) && fertile(second_id)) { continue; } self.food -= self.config.birth_food_cost; - self.spawn_person_at(self.home); - if let Some(partnership) = self - .partnerships - .iter_mut() - .find(|partnership| partnership.id == partnership_id) - { - partnership.last_birth_tick = self.tick; + let toddler = self.spawn_person_at(self.home); + census.people.push(toddler); + if let Some(actor) = self.partnership_actors.get(&partnership_id) { + actor.tell(PartnershipMsg::RecordBirth(self.tick), None); } self.say(&format!( "People #{first_id} and #{second_id} welcomed a toddler" @@ -1915,43 +1879,85 @@ impl World { } } - /// Updates hunger, deposits carried goods at home and eats when hungry. - fn feed_person(&mut self, p: &mut Person, night: bool, at_home: bool) { - 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; - self.stone += p.stone; - p.food = 0; - p.wood = 0; - p.stone = 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); + fn tick_people(&mut self, census: &mut Census) { + let performed = self.mailbox.request(); + let mut performers = 0; + for index in 0..census.people.len() { + if census.people[index].dead { + continue; + } + let Some(person) = self.meet_needs(&census.people[index]) else { + continue; + }; + census.people[index] = person.clone(); + if person.dead { + self.say(&format!("Person #{} starved", person.id)); + continue; + } + let activity = self.choose_activity(&person, census); + if let Some(actor) = self.person_actors.get(&person.id) { + actor.tell( + PersonMsg::Perform(performed, activity), + self.mailbox.reply_to(), + ); + performers += 1; } - self.craft_and_build(); } - 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); + for reply in self.mailbox.collect(performed, performers) { + let ReplyBody::Person(person) = reply else { + panic!("person actor answered an activity with {reply:?}"); + }; + if let Some(entry) = census.people.iter_mut().find(|p| p.id == person.id) { + *entry = person; + } } } + /// Lets a person get hungry and eat, settling deposits and meals with the stockpile. + fn meet_needs(&mut self, person: &Person) -> Option { + let actor = self.person_actors.get(&person.id)?; + let at_home = cheb(person.position(), self.home) <= self.config.home_radius; + let night = self.daylight() < self.config.daylight_night_threshold; + let needs = Needs { + at_home, + hunger_gain: if night && at_home { + self.config.hunger_night_at_home + } else { + self.config.hunger_other + }, + stock_food: self.food, + home_meal_threshold: self.config.hunger_eat_home_threshold, + carried_meal_threshold: self.config.hunger_eat_carried_threshold, + starvation_threshold: self.config.hunger_starvation_threshold, + meal_reduction: self.config.hunger_meal_reduction, + food_per_meal: self.config.food_per_meal, + }; + let reply = self.ask(actor, |request| PersonMsg::Needs(request, needs)); + let ReplyBody::Fed(person, outcome) = reply else { + panic!("person actor answered its needs with {reply:?}"); + }; + self.food += outcome.deposited.food; + self.wood += outcome.deposited.wood; + self.stone += outcome.deposited.stone; + if outcome.ate_from_stock { + self.food -= self.config.food_per_meal; + } + if at_home { + self.craft_and_build(); + } + Some(person) + } + /// Harvests `kind` at `target` when standing on it, otherwise walks there. - fn gather(&mut self, p: &mut Person, kind: u8, target: (usize, usize)) { - let pos = (p.x, p.y); + fn gather( + &mut self, + pos: (usize, usize), + kind: u8, + target: (usize, usize), + ) -> ((usize, usize), Goods) { let idx = pos.1 * self.width + pos.0; if pos != target || self.res[idx].0 != kind { - (p.x, p.y) = self.move_toward(pos, target); - return; + return (self.move_toward(pos, target), Goods::default()); } let yield_n: u16 = if self.tools >= self.population_u32() { self.config.person_tooled_gather_yield @@ -1963,48 +1969,65 @@ impl World { if self.res[idx].1 == 0 { self.res[idx].0 = 0; } - match kind { - BERRY => p.food += u32::from(take), - TREE => p.wood += u32::from(take), - _ => p.stone += u32::from(take), - } + let take = u32::from(take); + let gathered = match kind { + BERRY => Goods { + food: take, + ..Goods::default() + }, + TREE => Goods { + wood: take, + ..Goods::default() + }, + _ => Goods { + stone: take, + ..Goods::default() + }, + }; + (pos, gathered) } - fn tick_person(&mut self, i: usize) { - let mut p = self.people[i].clone(); - if p.dead { - return; - } + /// Asks a rabbit actor to suffer a successful attack; returns whether it died of it. + fn hunt_rabbit(&self, census: &mut Census, index: usize) -> bool { + let (id, _) = census.rabbits[index]; + let Some(actor) = self.rabbit_actors.get(&id) else { + return false; + }; + let reply = self.ask(actor, AnimalMsg::Hunted); + let ReplyBody::Hunted(_, killed) = reply else { + panic!("rabbit actor answered a hunt with {reply:?}"); + }; + census.rabbits[index].1.dead = true; + killed + } + + /// Decides what a fed person does this tick using the `person` behaviour script. + fn choose_activity(&mut self, p: &Person, census: &mut Census) -> Activity { let night = self.daylight() < self.config.daylight_night_threshold; - let pos = (p.x, p.y); + let pos = p.position(); let home = self.home; let at_home = cheb(pos, home) <= self.config.home_radius; - self.feed_person(&mut p, night, at_home); - if p.hunger >= self.config.hunger_starvation_threshold { - p.dead = true; - self.say(&format!("Person #{} starved", p.id)); - self.people[i] = p; - return; - } + let activity = |action: &str, position| Activity { + action: action.to_string(), + position, + gathered: Goods::default(), + }; if p.hunger > self.config.hunger_return_threshold && self.food > 0 && !at_home { - (p.x, p.y) = self.move_toward(pos, home); - p.action = "returning for food".to_string(); - self.people[i] = p; - return; + return activity("returning for food", self.move_toward(pos, home)); } if !p.stage.can_partner() { - if !at_home { - (p.x, p.y) = self.move_toward(pos, home); - } - p.action = "growing up".to_string(); - self.people[i] = p; - return; + let position = if at_home { + pos + } else { + self.move_toward(pos, home) + }; + return activity("growing up", position); } let berry = self.nearest_res(pos, BERRY, self.config.resource_search_radius); let tree = self.nearest_res(pos, TREE, self.config.resource_search_radius); let stone = self.nearest_res(pos, STONE, self.config.resource_search_radius); - let rabbit = Self::nearest_animal(&self.rabbits, pos, self.config.resource_search_radius); + let rabbit = Self::nearest_animal(&census.rabbits, pos, self.config.resource_search_radius); let ctx = Ctx { night, at_home, @@ -2029,40 +2052,43 @@ impl World { _ => None, }; match (choice.as_str(), gather, rabbit) { - ("sleep", ..) => p.action = "sleeping".to_string(), + ("sleep", ..) => activity("sleeping", pos), ("go_home" | "haul" | "eat", ..) => { - p.action = match choice.as_str() { + let action = match choice.as_str() { "go_home" => "going home", "haul" => "hauling goods", _ => "going to eat", - } - .to_string(); - if !at_home { - (p.x, p.y) = self.move_toward(pos, home); - } + }; + let position = if at_home { + pos + } else { + self.move_toward(pos, home) + }; + activity(action, position) } ("hunt", _, Some(rabbit)) => { - p.action = "hunting".to_string(); - let target = (self.rabbits[rabbit].x, self.rabbits[rabbit].y); - if cheb(pos, target) <= self.config.attack_range { - if self.rng.chance(self.config.person_hunt_success_chance) { - self.rabbits[rabbit].dead = true; - p.food += self.config.person_hunt_food_yield; - } - } else { - (p.x, p.y) = self.move_toward(pos, target); + let target = census.rabbits[rabbit].1.position(); + if cheb(pos, target) > self.config.attack_range { + return activity("hunting", self.move_toward(pos, target)); } + let mut hunting = activity("hunting", pos); + if self.rng.chance(self.config.person_hunt_success_chance) + && self.hunt_rabbit(census, rabbit) + { + hunting.gathered.food = self.config.person_hunt_food_yield; + } + hunting } (_, Some((kind, Some(target), label)), _) => { - p.action = label.to_string(); - self.gather(&mut p, kind, target); - } - _ => { - p.action = "wandering".to_string(); - (p.x, p.y) = self.wander(p.x, p.y); + let (position, gathered) = self.gather(pos, kind, target); + Activity { + action: label.to_string(), + position, + gathered, + } } + _ => activity("wandering", self.wander(pos.0, pos.1)), } - self.people[i] = p; } fn craft_and_build(&mut self) { @@ -2099,88 +2125,190 @@ impl World { } } - fn tick_rabbit(&mut self, i: usize) { - let mut a = self.rabbits[i].clone(); - 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, self.config.rabbit_wolf_detection_range); - let c = Ctx { - wolf_near: wolf.is_some(), - roll: f64::from(self.rng.f()), - ..Default::default() - }; - match (self.decide("rabbit", c).as_str(), wolf) { - ("flee", Some(w)) => { - let dx = direction(self.wolves[w].x, a.x); - let dy = direction(self.wolves[w].y, a.y); - (a.x, a.y) = self.move_dir(a.x, a.y, dx, dy); + /// Sends every animal of `species` its turn and returns the ids of those wanting to breed. + fn live( + &self, + species: Species, + census: &mut Census, + turns: Vec<(AnimalId, AnimalTurn)>, + ) -> Vec { + let request = self.mailbox.request(); + let mut sent = 0; + for (id, turn) in turns { + if let Some(actor) = self.animal_actors(species).get(&id) { + actor.tell(AnimalMsg::Live(request, turn), self.mailbox.reply_to()); + sent += 1; } - ("wander", _) => (a.x, a.y) = self.wander(a.x, a.y), - _ => {} } - 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: self.config.rabbit_newborn_energy, - dead: false, - }); + let lived = self + .mailbox + .collect(request, sent) + .into_iter() + .map(|reply| match reply { + ReplyBody::Lived(id, animal, wants_to_breed) => (id, (animal, wants_to_breed)), + other => panic!("animal actor answered its turn with {other:?}"), + }) + .collect(); + let animals = census.animals_mut(species); + let mut breeders = Vec::new(); + for (id, (animal, wants_to_breed)) in sorted_by_key(lived) { + if let Some(entry) = animals.iter_mut().find(|(key, _)| *key == id) { + entry.1 = animal; + } + if wants_to_breed { + breeders.push(id); + } } - if a.energy <= self.config.animal_energy_death_threshold { - a.dead = true; - } - self.rabbits[i] = a; + breeders } - fn tick_wolf(&mut self, i: usize) { - let mut wolf = self.wolves[i].clone(); - wolf.energy -= self.config.wolf_energy_drain; - let pos = (wolf.x, wolf.y); - let prey = Self::nearest_animal(&self.rabbits, pos, self.config.wolf_prey_range); - let ctx = Ctx { - prey_near: prey.is_some(), - ..Default::default() + /// Lets `breeders` give birth, in order, while the species is below `max_population`. + fn breed( + &mut self, + species: Species, + census: &mut Census, + breeders: Vec, + max_population: usize, + cost: BirthCost, + newborn_energy: f32, + ) { + for id in breeders { + let living = census + .animals_mut(species) + .iter() + .filter(|(_, animal)| !animal.dead) + .count(); + if living >= max_population { + break; + } + let Some(actor) = self.animal_actors(species).get(&id) else { + continue; + }; + let reply = self.ask(actor, |request| AnimalMsg::GiveBirth(request, cost)); + let ReplyBody::Animal(_, parent) = reply else { + panic!("animal actor answered a birth with {reply:?}"); + }; + let position = parent.position(); + if let Some(entry) = census + .animals_mut(species) + .iter_mut() + .find(|(key, _)| *key == id) + { + entry.1 = parent; + } + let newborn = self.spawn_animal(species, position, newborn_energy); + census.animals_mut(species).push(newborn); + } + } + + fn tick_rabbits(&mut self, census: &mut Census) { + let mut turns = Vec::with_capacity(census.rabbits.len()); + for index in 0..census.rabbits.len() { + let (id, rabbit) = census.rabbits[index].clone(); + if rabbit.dead { + continue; + } + let pos = rabbit.position(); + let on_grass = matches!(self.tiles[pos.1 * self.width + pos.0], GRASS | FOREST); + let wolf = + Self::nearest_animal(&census.wolves, pos, self.config.rabbit_wolf_detection_range); + let c = Ctx { + wolf_near: wolf.is_some(), + roll: f64::from(self.rng.f()), + ..Default::default() + }; + let position = match (self.decide("rabbit", c).as_str(), wolf) { + ("flee", Some(w)) => { + let (wx, wy) = census.wolves[w].1.position(); + self.move_dir(pos.0, pos.1, direction(wx, pos.0), direction(wy, pos.1)) + } + ("wander", _) => self.wander(pos.0, pos.1), + _ => pos, + }; + let may_breed = self.rng.chance(self.config.rabbit_breed_chance); + turns.push(( + id, + AnimalTurn { + drain: self.config.rabbit_energy_drain, + gain: on_grass.then_some(self.config.rabbit_energy_regen), + max_energy: self.config.rabbit_energy_max, + position, + breed_above: may_breed.then_some(self.config.rabbit_breed_energy), + death_threshold: self.config.animal_energy_death_threshold, + }, + )); + } + let breeders = self.live(Species::Rabbit, census, turns); + let cost = BirthCost { + energy: self.config.rabbit_breed_energy_cost, + death_threshold: self.config.animal_energy_death_threshold, }; - if let ("hunt", Some(rabbit)) = (self.decide("wolf", ctx).as_str(), prey) { - let target = (self.rabbits[rabbit].x, self.rabbits[rabbit].y); - if cheb(pos, target) <= self.config.attack_range { - if self.rng.chance(self.config.wolf_hunt_success_chance) { - self.rabbits[rabbit].dead = true; - wolf.energy = (wolf.energy + self.config.wolf_hunt_energy_gain) - .min(self.config.wolf_energy_max); + self.breed( + Species::Rabbit, + census, + breeders, + self.config.rabbit_max_population, + cost, + self.config.rabbit_newborn_energy, + ); + } + + fn tick_wolves(&mut self, census: &mut Census) { + let mut turns = Vec::with_capacity(census.wolves.len()); + for index in 0..census.wolves.len() { + let (id, wolf) = census.wolves[index].clone(); + if wolf.dead { + continue; + } + let pos = wolf.position(); + let prey = Self::nearest_animal(&census.rabbits, pos, self.config.wolf_prey_range); + let ctx = Ctx { + prey_near: prey.is_some(), + ..Default::default() + }; + let mut gain = None; + let position = if let ("hunt", Some(rabbit)) = (self.decide("wolf", ctx).as_str(), prey) + { + let target = census.rabbits[rabbit].1.position(); + if cheb(pos, target) <= self.config.attack_range { + if self.rng.chance(self.config.wolf_hunt_success_chance) + && self.hunt_rabbit(census, rabbit) + { + gain = Some(self.config.wolf_hunt_energy_gain); + } + pos + } else { + self.move_toward(pos, target) } } else { - (wolf.x, wolf.y) = self.move_toward(pos, target); - } - } else { - (wolf.x, wolf.y) = self.wander(wolf.x, wolf.y); + self.wander(pos.0, pos.1) + }; + let may_breed = self.rng.chance(self.config.wolf_breed_chance); + turns.push(( + id, + AnimalTurn { + drain: self.config.wolf_energy_drain, + gain, + max_energy: self.config.wolf_energy_max, + position, + breed_above: may_breed.then_some(self.config.wolf_breed_energy), + death_threshold: self.config.animal_energy_death_threshold, + }, + )); } - if wolf.energy > self.config.wolf_breed_energy - && self.wolves.len() < self.config.wolf_max_population - && self.rng.chance(self.config.wolf_breed_chance) - { - wolf.energy -= self.config.wolf_breed_energy_cost; - self.wolves.push(Animal { - actor_id: 0, - x: wolf.x, - y: wolf.y, - energy: self.config.wolf_newborn_energy, - dead: false, - }); - } - if wolf.energy <= self.config.animal_energy_death_threshold { - wolf.dead = true; - } - self.wolves[i] = wolf; + let breeders = self.live(Species::Wolf, census, turns); + let cost = BirthCost { + energy: self.config.wolf_breed_energy_cost, + death_threshold: self.config.animal_energy_death_threshold, + }; + self.breed( + Species::Wolf, + census, + breeders, + self.config.wolf_max_population, + cost, + self.config.wolf_newborn_energy, + ); } /// Replaces the running script. Behaviours and tuning constants apply @@ -2216,6 +2344,7 @@ impl World { } pub fn snapshot_json(&self) -> Result { + let census = self.census(); serde_json::to_string(&WorldSnapshot { version: 1, seed: Some(self.seed), @@ -2223,9 +2352,9 @@ impl World { height: self.height, tiles: self.tiles.clone(), res: self.res.clone(), - people: self.people.clone(), - rabbits: self.rabbits.clone(), - wolves: self.wolves.clone(), + people: census.people, + rabbits: census.rabbits.into_iter().map(|(_, a)| a).collect(), + wolves: census.wolves.into_iter().map(|(_, a)| a).collect(), houses: self.houses.clone(), home: self.home, food: self.food, @@ -2240,7 +2369,7 @@ impl World { speed: self.speed, terrain_rev: self.terrain_rev, calendar: self.calendar.clone(), - partnerships: self.partnerships.clone(), + partnerships: self.observe_partnerships(), next_partnership_id: self.next_partnership_id, }) } @@ -2261,7 +2390,7 @@ impl World { fallback_seed: u64, script: SimScript, ) -> Result { - let config = script.config.clone(); + let config = &script.config; let snapshot_value: serde_json::Value = serde_json::from_str(snapshot).map_err(|e| e.to_string())?; let legacy_people = snapshot_value @@ -2298,81 +2427,58 @@ impl World { { return Err("world snapshot has invalid terrain dimensions".into()); } - 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, - rabbits: saved.rabbits, - wolves: saved.wolves, - partnerships: saved.partnerships, - next_partnership_id: saved.next_partnership_id, - actor_system: ActorSystem::new() - .map_err(|e| format!("failed to start Picard actor system: {e}"))?, - entities: HashMap::new(), - next_actor_id: 1, - houses: saved.houses, - home: saved.home, - food: saved.food, - wood: saved.wood, - stone: saved.stone, - tools: saved.tools, - rng: Rng(saved.rng), - tick: saved.tick, - next_id: saved.next_id, - log: saved.log, - vm: script.vm(), + let mut world = Self::empty( + saved.seed.unwrap_or(fallback_seed), script, - paused: saved.paused, - speed: saved.speed, - terrain_rev: saved.terrain_rev, - calendar: saved.calendar, - config, - }; + saved.tiles, + saved.res, + ); + world.width = saved.width; + world.height = saved.height; + world.next_partnership_id = saved.next_partnership_id; + world.houses = saved.houses; + world.home = saved.home; + world.food = saved.food; + world.wood = saved.wood; + world.stone = saved.stone; + world.tools = saved.tools; + world.rng = Rng(saved.rng); + world.tick = saved.tick; + world.next_id = saved.next_id; + world.log = saved.log; + world.paused = saved.paused; + world.speed = saved.speed; + world.terrain_rev = saved.terrain_rev; + world.calendar = saved.calendar; world.calendar.normalize(); + let mut people = saved.people; if legacy_people { - world.initialize_legacy_people(); + for (index, person) in people.iter_mut().enumerate() { + let stage = if index < world.config.initial_young_adults { + LifeStage::YoungAdult + } else { + LifeStage::Adult + }; + world.make_grown_up(person, stage); + } + } + let mut partnerships = saved.partnerships; + repair_partnerships(&mut people, &mut partnerships); + for person in people { + world.spawn_person_actor(person); + } + for rabbit in saved.rabbits { + world.spawn_animal_actor(Species::Rabbit, rabbit); + } + for wolf in saved.wolves { + world.spawn_animal_actor(Species::Wolf, wolf); + } + for partnership in partnerships { + world.spawn_partnership_actor(partnership); } - world.prune_partnerships(); - world.sync_entities(); Ok(world) } - fn initialize_legacy_people(&mut self) { - for index in 0..self.people.len() { - let stage = if index < self.config.initial_young_adults { - LifeStage::YoungAdult - } else { - LifeStage::Adult - }; - let duration_days = self.sample_stage_duration(stage); - let prior_stages = match stage { - LifeStage::YoungAdult => { - [LifeStage::Toddler, LifeStage::Child, LifeStage::Teen].as_slice() - } - LifeStage::Adult => [ - LifeStage::Toddler, - LifeStage::Child, - LifeStage::Teen, - LifeStage::YoungAdult, - ] - .as_slice(), - _ => &[], - }; - let age_days: u64 = prior_stages - .iter() - .map(|&past_stage| u64::from(self.sample_stage_duration(past_stage))) - .sum(); - let person = &mut self.people[index]; - person.stage = stage; - person.stage_duration_days = duration_days; - person.stage_elapsed_ticks = 0; - person.age_ticks = age_days * self.calendar.ticks_per_day; - } - } - fn 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 { @@ -2389,7 +2495,6 @@ impl World { /// Applies one whitespace-separated edit command; malformed commands are ignored. pub fn apply(&mut self, cmd: &str) { - self.load_entities(); let t: Vec<&str> = cmd.split_whitespace().collect(); match t.first().copied() { Some("pause") => self.paused = arg(&t, 1) != Some(0), @@ -2417,8 +2522,6 @@ impl World { Some("remove") => self.edit_remove(&t), _ => {} } - self.prune_partnerships(); - self.sync_entities(); } fn edit_calendar(&mut self, t: &[&str]) { @@ -2473,16 +2576,17 @@ impl World { let max_inventory = i64::from(self.config.max_person_inventory_edit); let max_hunger = self.config.max_hunger_edit; if let (Some(id), Some(field), Some(v)) = (arg(t, 1), t.get(2), arg(t, 3)) - && let Some(p) = self.people.iter_mut().find(|p| i64::from(p.id) == id) + && let Ok(id) = u32::try_from(id) { let u = clamp_to(v, 0, max_inventory); - match *field { - "hunger" => p.hunger = small_f32(v.clamp(0, max_hunger)), - "food" => p.food = u, - "wood" => p.wood = u, - "stone" => p.stone = u, - _ => {} - } + let edit = match *field { + "hunger" => PersonEdit::Hunger(small_f32(v.clamp(0, max_hunger))), + "food" => PersonEdit::Food(u), + "wood" => PersonEdit::Wood(u), + "stone" => PersonEdit::Stone(u), + _ => return, + }; + self.tell_person(id, PersonMsg::Edit(edit)); } } @@ -2490,11 +2594,9 @@ impl World { if let (Some((x0, y0, x1, y1)), Some(v)) = (self.rect(t, 1), arg(t, 5)) { let max = self.config.max_animal_energy_edit.trunc().max(1.0); let v = small_f32(v.clamp(1, 1_000_000)).min(max); - let 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) { - a.energy = v; - } + let area = Area { x0, y0, x1, y1 }; + for actor in self.rabbit_actors.values().chain(self.wolf_actors.values()) { + actor.tell(AnimalMsg::SetEnergyWithin(area, v), None); } } } @@ -2537,17 +2639,17 @@ impl World { continue; } left -= 1; - let a = Animal { - actor_id: 0, - x, - y, - energy: self.config.initial_animal_energy, - dead: false, - }; + let energy = self.config.initial_animal_energy; match *what { - "person" => self.spawn_person_at((x, y)), - "rabbit" => self.rabbits.push(a), - "wolf" => self.wolves.push(a), + "person" => { + self.spawn_person_at((x, y)); + } + "rabbit" => { + self.spawn_animal(Species::Rabbit, (x, y), energy); + } + "wolf" => { + self.spawn_animal(Species::Wolf, (x, y), energy); + } _ => left += 1, } } @@ -2576,19 +2678,34 @@ impl World { let (Some((x0, y0, x1, y1)), Some(what)) = (self.rect(t, 1), t.get(5)) else { return; }; - let inside = |x: usize, y: usize| x >= x0 && x <= x1 && y >= y0 && y <= y1; + let area = Area { x0, y0, x1, y1 }; let all = *what == "all"; if all || *what == "people" { - self.people.retain(|p| !inside(p.x, p.y)); + let mut people = self.observe_people(); + let removed: Vec = people + .iter() + .filter(|p| area.contains(p.position())) + .map(|p| p.id) + .collect(); + if !removed.is_empty() { + for &id in &removed { + self.retire_person(id); + } + people.retain(|p| !removed.contains(&p.id)); + self.prune_partnerships(&mut people); + } } - if all || *what == "rabbits" { - self.rabbits.retain(|a| !inside(a.x, a.y)); - } - if all || *what == "wolves" { - self.wolves.retain(|a| !inside(a.x, a.y)); + for (name, species) in [("rabbits", Species::Rabbit), ("wolves", Species::Wolf)] { + if all || *what == name { + for (id, animal) in self.observe_animals(species) { + if area.contains(animal.position()) { + self.retire_animal(species, id); + } + } + } } if all || *what == "houses" { - self.houses.retain(|h| !inside(h.0, h.1)); + self.houses.retain(|h| !area.contains(*h)); } if all || *what == "res" { for y in y0..=y1 { @@ -2614,15 +2731,17 @@ impl World { } } if !land { - let ok = |tiles: &[u8], x: usize, y: usize| { - 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)); - self.wolves.retain(|a| ok(&tiles, a.x, a.y)); - let home = self.home; - for p in self.people.iter_mut().filter(|p| !ok(&tiles, p.x, p.y)) { - (p.x, p.y) = home; + for species in [Species::Rabbit, Species::Wolf] { + for (id, animal) in self.observe_animals(species) { + if !self.walkable(animal.position()) { + self.retire_animal(species, id); + } + } + } + for person in self.observe_people() { + if !self.walkable(person.position()) { + self.tell_person(person.id, PersonMsg::MoveTo(self.home)); + } } } self.terrain_rev += 1; @@ -2670,6 +2789,7 @@ impl World { } fn write_entities_json(&self, s: &mut String) { + let census = self.census(); s.push_str("\"houses\":["); let j: Vec = self .houses @@ -2678,7 +2798,7 @@ impl World { .collect(); s.push_str(&j.join(",")); s.push_str("],\"people\":["); - let j: Vec = self + let j: Vec = census .people .iter() .map(|p| { @@ -2701,17 +2821,17 @@ impl World { .collect(); s.push_str(&j.join(",")); s.push_str("],\"rabbits\":["); - let j: Vec = self + let j: Vec = census .rabbits .iter() - .map(|a| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) + .map(|(_, a)| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) .collect(); s.push_str(&j.join(",")); s.push_str("],\"wolves\":["); - let j: Vec = self + let j: Vec = census .wolves .iter() - .map(|a| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) + .map(|(_, a)| format!("[{},{},{:.0}]", a.x, a.y, a.energy)) .collect(); s.push_str(&j.join(",")); s.push_str("],\"res\":["); @@ -2810,10 +2930,8 @@ impl Drop for World { #[cfg(test)] mod tests { - use super::{ - DEFAULT_SCRIPT, EntityKind, EntityMessage, EntityState, H, LifeStage, Partnership, - SimScript, W, World, cheb, default_config, - }; + use super::actors::{Activity, AnimalTurn, Goods, PersonMsg, ReplyBody}; + use super::{Animal, DEFAULT_SCRIPT, H, LifeStage, SimScript, W, World, cheb, default_config}; /// Replaces the value of `pub const {name}` in `source`, whatever its current value is. fn with_constant(source: &str, name: &str, value: &str) -> String { @@ -2865,9 +2983,11 @@ mod tests { duration.min_days = 1; duration.max_days = 1; } - world.spawn_person(); - let index = world.people.len() - 1; - assert_eq!(world.people[index].stage, LifeStage::Toddler); + let id = world.spawn_person_at(world.home).id; + assert_eq!( + world.observe_people().last().unwrap().stage, + LifeStage::Toddler + ); for expected in [ LifeStage::Child, @@ -2878,10 +2998,12 @@ mod tests { LifeStage::Elderly, LifeStage::Dead, ] { - world.advance_people(); - assert_eq!(world.people[index].stage, expected); + let people = world.advance_people(); + let person = people.iter().find(|person| person.id == id).unwrap(); + assert_eq!(person.stage, expected); } - assert!(world.people[index].dead); + let people = world.observe_people(); + assert!(people.iter().find(|person| person.id == id).unwrap().dead); } #[test] @@ -2890,45 +3012,37 @@ mod tests { world.calendar.ticks_per_day = 1; world.calendar.days_per_week = 1; world.food = 1000; - let eligible = world + let mut census = world.census(); + let eligible = census .people .iter() .filter(|person| person.stage.can_partner()) .count(); - world.find_partners(); + world.find_partners(&mut census.people); let partnerships = eligible / 2; assert!(partnerships > 0); - assert_eq!(world.partnerships.len(), partnerships); + assert_eq!(world.partnership_actors.len(), partnerships); + assert_eq!(world.observe_partnerships().len(), partnerships); assert_eq!( world - .people + .observe_people() .iter() .filter(|person| person.partner_id.is_some()) .count(), partnerships * 2 ); - world.people[0].x = 0; - world.people[0].y = 0; - world.sync_entities(); - assert_eq!( - world.entities.len(), - world.people.len() + world.rabbits.len() + world.wolves.len() + partnerships - ); - world.load_entities(); - assert_eq!(world.partnerships.len(), partnerships); - - let initial_population = world.people.len(); + let initial_population = census.people.len(); let births = 3; world.config.population_capacity = initial_population + births; - while world.people.len() < world.population_capacity() { + while census.people.len() < world.population_capacity() { world.tick += 1; - world.have_children(1); + world.have_children(1, &mut census); } - assert_eq!(world.people.len(), world.population_capacity()); - assert_eq!(world.people.len() - initial_population, births); - let toddler = world - .people + let people = world.observe_people(); + assert_eq!(people.len(), world.population_capacity()); + assert_eq!(people.len() - initial_population, births); + let toddler = people .iter() .find(|person| person.stage == LifeStage::Toddler) .expect("fertile partner actors should eventually have a child"); @@ -2940,35 +3054,33 @@ mod tests { fn each_fertile_partnership_can_have_a_weekly_birth() { let mut world = World::new(42); world.calendar.ticks_per_day = 1; - world.find_partners(); - let fertile_partnerships = world - .partnerships + let mut census = world.census(); + world.find_partners(&mut census.people); + let partnerships = world.observe_partnerships(); + let fertile_partnerships = partnerships .iter() .filter(|partnership| { [partnership.first_person_id, partnership.second_person_id] .iter() .all(|id| { - let person = world.people.iter().find(|person| person.id == *id); + let person = census.people.iter().find(|person| person.id == *id); person.is_some_and(|person| is_fertile(&world, person.stage)) }) }) .count(); assert!(fertile_partnerships > 0); - let initial_population = world.people.len(); - world.config.population_capacity = initial_population + world.partnerships.len(); + let initial_population = census.people.len(); + world.config.population_capacity = initial_population + partnerships.len(); world.food = world.config.birth_food_threshold + world.config.birth_food_cost * u32::try_from(fertile_partnerships).unwrap(); world.tick += 7; - world.have_children(7); + world.have_children(7, &mut census); + let people = world.observe_people(); + assert_eq!(people.len(), initial_population + fertile_partnerships); assert_eq!( - world.people.len(), - initial_population + fertile_partnerships - ); - assert_eq!( - world - .people + people .iter() .filter(|person| person.stage == LifeStage::Toddler) .count(), @@ -3003,7 +3115,7 @@ mod tests { while world.tick < first_week_tick { world.step(); if world.tick.is_multiple_of(world.calendar.ticks_per_day) { - daily.push((world.tick, world.people.len(), world.food)); + daily.push((world.tick, world.person_actors.len(), world.food)); } } @@ -3014,10 +3126,10 @@ mod tests { initial_population }; assert!( - world.people.len() >= minimum_population, + world.person_actors.len() >= minimum_population, "village failed to replace its initial population: daily={daily:?}, people={:?}, events={:?}", world - .people + .observe_people() .iter() .map(|person| (person.id, person.hunger, person.action.as_str())) .collect::>(), @@ -3052,11 +3164,11 @@ mod tests { let restored = World::from_snapshot(&snapshot.to_string(), 42).unwrap(); let config = default_config(); - assert_eq!(restored.people.len(), config.initial_population); - assert_eq!(restored.people[0].stage, LifeStage::YoungAdult); + let people = restored.observe_people(); + assert_eq!(people.len(), config.initial_population); + assert_eq!(people[0].stage, LifeStage::YoungAdult); assert!( - restored - .people + people .iter() .skip(config.initial_young_adults) .all(|person| person.stage == LifeStage::Adult) @@ -3066,12 +3178,15 @@ mod tests { #[test] fn partnership_and_life_stage_are_in_persisted_actor_state() { let mut world = World::new(42); - world.find_partners(); - world.sync_entities(); + let mut census = world.census(); + world.find_partners(&mut census.people); let restored = World::from_snapshot(&world.snapshot_json().unwrap(), 42).unwrap(); - assert!(!world.partnerships.is_empty()); - assert_eq!(restored.partnerships.len(), world.partnerships.len()); + assert!(!world.partnership_actors.is_empty()); + assert_eq!( + restored.observe_partnerships().len(), + world.partnership_actors.len() + ); assert_eq!(simulation_state(&restored), simulation_state(&world)); assert_eq!( serde_json::from_str::(&restored.state_json()).unwrap()["people"][0] @@ -3086,25 +3201,31 @@ mod tests { world.food = 100; let home = world.home; let position = (home.0.saturating_add(5), home.1.saturating_add(5)); - world.spawn_person_at(position); + let id = world.spawn_person_at(position).id; - world.tick_person(world.people.len() - 1); + let mut census = world.census(); + world.tick_people(&mut census); - let child = world.people.last().unwrap(); + let child = census.people.iter().find(|person| person.id == id).unwrap(); assert!(cheb((child.x, child.y), home) < cheb(position, home)); assert_eq!(child.action, "growing up"); + let observed = world.observe_people(); + let observed = observed.iter().find(|person| person.id == id).unwrap(); + assert_eq!((observed.x, observed.y), (child.x, child.y)); } #[test] fn a_person_eats_available_food_before_starving() { let mut world = World::new(42); world.food = 1; - world.people[0].hunger = 99.0; + let id = world.observe_people()[0].id; + world.apply(&format!("person {id} hunger 99")); - world.tick_person(0); + let person = world.observe_people()[0].clone(); + let fed = world.meet_needs(&person).unwrap(); - assert!(!world.people[0].dead); - assert!(world.people[0].hunger < 100.0); + assert!(!fed.dead); + assert!(fed.hunger < 100.0); assert_eq!(world.food, 0); } @@ -3138,7 +3259,10 @@ mod tests { assert_eq!(reset.seed(), 42); assert_eq!(reset.speed, 4); assert!(reset.paused); - assert_eq!(reset.people.len(), default_config().initial_population); + assert_eq!( + reset.person_actors.len(), + default_config().initial_population + ); assert_eq!( reset.tick, default_config().calendar.ticks_per_day / default_config().starting_tick_fraction @@ -3152,51 +3276,112 @@ mod tests { } #[test] - fn entity_actor_messages_preserve_actor_ids_through_serde() { - let message = EntityMessage::Write(EntityState { - id: 7, - kind: EntityKind::Partnership(Partnership { - id: 1, - first_person_id: 2, - second_person_id: 3, - formed_tick: 4, - last_birth_tick: 5, - actor_id: 7, - }), - }); + fn entity_actor_messages_round_trip_through_serde() { + let message = PersonMsg::Perform( + 7, + Activity { + action: "foraging".into(), + position: (3, 4), + gathered: Goods { + food: 2, + wood: 0, + stone: 1, + }, + }, + ); let serialized = serde_json::to_string(&message).unwrap(); - let EntityMessage::Write(mut restored) = serde_json::from_str(&serialized).unwrap() else { - panic!("serialized entity write message changed variants"); + let PersonMsg::Perform(request, activity) = serde_json::from_str(&serialized).unwrap() + else { + panic!("serialized person message changed variants"); }; - restored.restore_actor_id(); + assert_eq!((request, activity.position), (7, (3, 4))); + assert_eq!(activity.gathered.stone, 1); - let EntityKind::Partnership(partnership) = restored.kind else { - panic!("serialized entity write message changed entity kinds"); + let reply = ReplyBody::Lived( + 9, + Animal { + x: 1, + y: 2, + energy: 30.0, + dead: false, + }, + true, + ); + let serialized = serde_json::to_string(&reply).unwrap(); + let ReplyBody::Lived(id, animal, wants_to_breed) = + serde_json::from_str(&serialized).unwrap() + else { + panic!("serialized animal reply changed variants"); }; - assert_eq!(partnership.actor_id, 7); + assert_eq!((id, animal.position(), wants_to_breed), (9, (1, 2), true)); + let turn: AnimalTurn = serde_json::from_str( + r#"{"drain":1.0,"gain":null,"max_energy":2.0,"position":[0,0],"breed_above":null,"death_threshold":0.0}"#, + ) + .unwrap(); + assert!(turn.gain.is_none()); } #[test] fn picard_actors_own_living_entity_state() { let mut world = World::new(42); - let person_id = world.people[0].id; + let person_id = world.observe_people()[0].id; world.apply(&format!("person {person_id} hunger 73")); - world.people[0].hunger = 1.0; - world.load_entities(); - assert!((world.people[0].hunger - 73.0).abs() < f32::EPSILON); + assert!((world.observe_people()[0].hunger - 73.0).abs() < f32::EPSILON); + let entities = world.person_actors.len() + + world.rabbit_actors.len() + + world.wolf_actors.len() + + world.partnership_actors.len(); + let census = world.census(); assert_eq!( - world.entities.len(), - world.people.len() + world.rabbits.len() + world.wolves.len() + census.people.len() + census.rabbits.len() + census.wolves.len(), + entities ); let state: serde_json::Value = serde_json::from_str(&world.state_json()).unwrap(); - assert!( - state["actor_stats"]["actors_created"].as_u64().unwrap() >= world.entities.len() as u64 - ); + assert!(state["actor_stats"]["live_actors"].as_u64().unwrap() > entities as u64); assert!(state["actor_stats"]["messages_sent"].as_u64().unwrap() > 0); } + #[test] + fn dead_entities_stop_their_actors_and_dissolve_partnerships() { + let mut world = World::new(42); + let mut census = world.census(); + world.find_partners(&mut census.people); + let partnership = world.observe_partnerships()[0].clone(); + let partner = census + .people + .iter() + .find(|person| person.id == partnership.first_person_id) + .unwrap() + .clone(); + let survivor_id = partnership.second_person_id; + + world.apply(&format!( + "remove {x} {y} {x} {y} people", + x = partner.x, + y = partner.y + )); + + assert!(!world.person_actors.contains_key(&partner.id)); + assert!(!world.partnership_actors.contains_key(&partnership.id)); + if let Some(survivor) = world + .observe_people() + .iter() + .find(|person| person.id == survivor_id) + { + assert_eq!(survivor.partner_id, None); + } + + let rabbit = world.observe_animals(super::Species::Rabbit)[0].clone(); + world.apply(&format!( + "remove {x} {y} {x} {y} rabbits", + x = rabbit.1.x, + y = rabbit.1.y + )); + assert!(!world.rabbit_actors.contains_key(&rabbit.0)); + } + #[test] fn calendar_and_life_stage_ranges_persist_in_snapshots() { let mut world = World::new(42); @@ -3246,7 +3431,7 @@ mod tests { 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.person_actors.len(), 6); assert_eq!(world.food, 125); assert_eq!(world.calendar.ticks_per_day, 240); assert!(world.terrain_json().contains("\"w\":48,\"h\":32")); diff --git a/src/world/actors.rs b/src/world/actors.rs new file mode 100644 index 0000000..8b3c717 --- /dev/null +++ b/src/world/actors.rs @@ -0,0 +1,496 @@ +//! Picard actors that own the state of every simulated person, animal and partnership. +//! +//! The world never keeps its own copy of entity state: it asks the actors for their state, +//! sends them the effects of world-level decisions (movement, gathering, hunting, births) and +//! lets them apply entity-local rules (ageing, hunger, eating, energy, dying) themselves. + +use super::{Animal, CalendarSettings, LifeStage, Partnership, Person, Rng, sample_stage_duration}; +use picard::actors::{ + Actor, ActorFactoryArgs, ActorRef, ActorRefFactory, ActorSystem, Context as ActorContext, + Sender, +}; +use serde::{Deserialize, Serialize}; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::sync::mpsc::{Receiver, Sender as MpscSender, channel}; +use std::time::Duration; + +/// Correlates replies with the request (or broadcast) that caused them. +pub(super) type RequestId = u64; + +/// Key of a rabbit or wolf actor; animals have no other identity. +pub(super) type AnimalId = u64; + +const REPLY_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub(super) struct Goods { + pub food: u32, + pub wood: u32, + pub stone: u32, +} + +/// Inclusive map rectangle. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub(super) struct Area { + pub x0: usize, + pub y0: usize, + pub x1: usize, + pub y1: usize, +} + +impl Area { + pub fn contains(&self, (x, y): (usize, usize)) -> bool { + x >= self.x0 && x <= self.x1 && y >= self.y0 && y <= self.y1 + } +} + +/// Hunger and eating rules for one person tick, with the shared stockpile the world offers. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct Needs { + pub at_home: bool, + pub hunger_gain: f32, + /// Food in the shared stockpile before this person deposits anything. + pub stock_food: u32, + pub home_meal_threshold: f32, + pub carried_meal_threshold: f32, + pub starvation_threshold: f32, + pub meal_reduction: f32, + pub food_per_meal: u32, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +pub(super) struct NeedsOutcome { + /// Goods moved from the person's inventory into the shared stockpile. + pub deposited: Goods, + /// The person ate one meal from the shared stockpile. + pub ate_from_stock: bool, +} + +/// What a person does this tick, as decided by the world and its behaviour script. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct Activity { + pub action: String, + pub position: (usize, usize), + pub gathered: Goods, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub(super) enum PersonEdit { + Hunger(f32), + Food(u32), + Wood(u32), + Stone(u32), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum PersonMsg { + Observe(RequestId), + /// Ages the person by one tick, moving on to the next life stage when this one is over. + Age(RequestId, CalendarSettings), + Needs(RequestId, Needs), + Perform(RequestId, Activity), + Edit(PersonEdit), + SetPartner(Option), + MoveTo((usize, usize)), +} + +/// One animal tick: the world supplies movement and opportunities, the animal its metabolism. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct AnimalTurn { + pub drain: f32, + /// Energy gained this tick from grazing or a successful hunt. + pub gain: Option, + pub max_energy: f32, + pub position: (usize, usize), + /// Energy above which the animal wants to breed; `None` when it may not try this tick. + pub breed_above: Option, + pub death_threshold: f32, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub(super) struct BirthCost { + pub energy: f32, + pub death_threshold: f32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum AnimalMsg { + Observe(RequestId), + Live(RequestId, AnimalTurn), + GiveBirth(RequestId, BirthCost), + /// A successful attack; only the first one kills the animal. + Hunted(RequestId), + SetEnergyWithin(Area, f32), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum PartnershipMsg { + Observe(RequestId), + /// Asks whether `interval` ticks have passed since the last birth. + BirthDue { + request: RequestId, + tick: u64, + interval: u64, + }, + RecordBirth(u64), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum ReplyBody { + Person(Person), + /// The person after ageing, and the stage it entered this tick. + Aged(Person, Option), + Fed(Person, NeedsOutcome), + Animal(AnimalId, Animal), + /// The animal after its turn and whether it wants to breed. + Lived(AnimalId, Animal, bool), + /// Whether the attack killed the animal. + Hunted(AnimalId, bool), + Partnership(Partnership), + BirthDue(Partnership, bool), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct Reply { + pub request: RequestId, + pub body: ReplyBody, +} + +fn reply(sender: Sender, request: RequestId, body: ReplyBody) { + // Messages sent without a reply address (e.g. from the actor console) get no answer. + if let Some(sender) = sender { + let _ = sender.try_tell(Reply { request, body }, None); + } +} + +pub(super) struct PersonActor { + person: Person, +} + +impl ActorFactoryArgs for PersonActor { + fn create_args(mut person: Person) -> Self { + if person.rng == 0 { + // Snapshots from before people had their own random stream. + person.rng = u64::from(person.id).wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + } + Self { person } + } +} + +impl PersonActor { + fn age(&mut self, calendar: &CalendarSettings) -> Option { + let person = &mut self.person; + if person.dead { + return None; + } + person.age_ticks = person.age_ticks.saturating_add(1); + person.stage_elapsed_ticks = person.stage_elapsed_ticks.saturating_add(1); + let duration_ticks = u64::from(person.stage_duration_days) * calendar.ticks_per_day; + if person.stage_elapsed_ticks < duration_ticks { + return None; + } + let next = person.stage.next().unwrap_or(LifeStage::Dead); + person.stage = next; + person.stage_elapsed_ticks = 0; + if next == LifeStage::Dead { + person.dead = true; + } else { + let mut rng = Rng(person.rng); + person.stage_duration_days = sample_stage_duration(calendar, next, &mut rng); + person.rng = rng.0; + } + Some(next) + } + + fn meet_needs(&mut self, needs: &Needs) -> NeedsOutcome { + let person = &mut self.person; + let mut outcome = NeedsOutcome::default(); + person.hunger += needs.hunger_gain; + if needs.at_home { + outcome.deposited = Goods { + food: std::mem::take(&mut person.food), + wood: std::mem::take(&mut person.wood), + stone: std::mem::take(&mut person.stone), + }; + if person.hunger > needs.home_meal_threshold + && needs.stock_food + outcome.deposited.food >= needs.food_per_meal + { + outcome.ate_from_stock = true; + person.hunger = (person.hunger - needs.meal_reduction).max(0.0); + } + } + if person.hunger > needs.carried_meal_threshold && person.food >= needs.food_per_meal { + person.food -= needs.food_per_meal; + person.hunger = (person.hunger - needs.meal_reduction).max(0.0); + } + if person.hunger >= needs.starvation_threshold { + person.dead = true; + } + outcome + } + + fn perform(&mut self, activity: Activity) { + let person = &mut self.person; + person.action = activity.action; + (person.x, person.y) = activity.position; + person.food += activity.gathered.food; + person.wood += activity.gathered.wood; + person.stone += activity.gathered.stone; + } + + fn edit(&mut self, edit: PersonEdit) { + let person = &mut self.person; + match edit { + PersonEdit::Hunger(hunger) => person.hunger = hunger, + PersonEdit::Food(food) => person.food = food, + PersonEdit::Wood(wood) => person.wood = wood, + PersonEdit::Stone(stone) => person.stone = stone, + } + } +} + +impl Actor for PersonActor { + type Msg = PersonMsg; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { + match msg { + PersonMsg::Observe(request) => { + reply(sender, request, ReplyBody::Person(self.person.clone())); + } + PersonMsg::Age(request, calendar) => { + let stage = self.age(&calendar); + reply(sender, request, ReplyBody::Aged(self.person.clone(), stage)); + } + PersonMsg::Needs(request, needs) => { + let outcome = self.meet_needs(&needs); + reply( + sender, + request, + ReplyBody::Fed(self.person.clone(), outcome), + ); + } + PersonMsg::Perform(request, activity) => { + self.perform(activity); + reply(sender, request, ReplyBody::Person(self.person.clone())); + } + PersonMsg::Edit(edit) => self.edit(edit), + PersonMsg::SetPartner(partner_id) => self.person.partner_id = partner_id, + PersonMsg::MoveTo(position) => (self.person.x, self.person.y) = position, + } + } +} + +pub(super) struct AnimalActor { + id: AnimalId, + animal: Animal, +} + +impl ActorFactoryArgs<(AnimalId, Animal)> for AnimalActor { + fn create_args((id, animal): (AnimalId, Animal)) -> Self { + Self { id, animal } + } +} + +impl AnimalActor { + fn live(&mut self, turn: &AnimalTurn) -> bool { + let animal = &mut self.animal; + if animal.dead { + return false; + } + animal.energy -= turn.drain; + if let Some(gain) = turn.gain { + animal.energy = (animal.energy + gain).min(turn.max_energy); + } + (animal.x, animal.y) = turn.position; + if animal.energy <= turn.death_threshold { + animal.dead = true; + return false; + } + turn.breed_above + .is_some_and(|threshold| animal.energy > threshold) + } + + fn give_birth(&mut self, cost: BirthCost) { + let animal = &mut self.animal; + animal.energy -= cost.energy; + if animal.energy <= cost.death_threshold { + animal.dead = true; + } + } + + fn hunted(&mut self) -> bool { + let killed = !self.animal.dead; + self.animal.dead = true; + killed + } +} + +impl Actor for AnimalActor { + type Msg = AnimalMsg; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { + match msg { + AnimalMsg::Observe(request) => { + reply( + sender, + request, + ReplyBody::Animal(self.id, self.animal.clone()), + ); + } + AnimalMsg::Live(request, turn) => { + let wants_to_breed = self.live(&turn); + let body = ReplyBody::Lived(self.id, self.animal.clone(), wants_to_breed); + reply(sender, request, body); + } + AnimalMsg::GiveBirth(request, cost) => { + self.give_birth(cost); + reply( + sender, + request, + ReplyBody::Animal(self.id, self.animal.clone()), + ); + } + AnimalMsg::Hunted(request) => { + let killed = self.hunted(); + reply(sender, request, ReplyBody::Hunted(self.id, killed)); + } + AnimalMsg::SetEnergyWithin(area, energy) => { + if area.contains((self.animal.x, self.animal.y)) { + self.animal.energy = energy; + } + } + } + } +} + +pub(super) struct PartnershipActor { + partnership: Partnership, +} + +impl ActorFactoryArgs for PartnershipActor { + fn create_args(partnership: Partnership) -> Self { + Self { partnership } + } +} + +impl Actor for PartnershipActor { + type Msg = PartnershipMsg; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { + match msg { + PartnershipMsg::Observe(request) => { + reply( + sender, + request, + ReplyBody::Partnership(self.partnership.clone()), + ); + } + PartnershipMsg::BirthDue { + request, + tick, + interval, + } => { + let due = tick.saturating_sub(self.partnership.last_birth_tick) >= interval; + reply( + sender, + request, + ReplyBody::BirthDue(self.partnership.clone(), due), + ); + } + PartnershipMsg::RecordBirth(tick) => self.partnership.last_birth_tick = tick, + } + } +} + +/// Forwards entity replies to the simulation thread. +struct InboxActor { + replies: MpscSender, +} + +impl ActorFactoryArgs> for InboxActor { + fn create_args(replies: MpscSender) -> Self { + Self { replies } + } +} + +impl Actor for InboxActor { + type Msg = Reply; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, _sender: Sender) { + // The receiver only disappears while the world is being dropped. + let _ = self.replies.send(msg); + } +} + +/// The world's reply address. Requests are answered asynchronously; `collect` waits for the +/// replies of one request while keeping replies to other outstanding requests for later. +pub(super) struct Mailbox { + inbox: ActorRef, + replies: Receiver, + next_request: Cell, + outstanding: RefCell>>, +} + +impl Mailbox { + pub fn new(actor_system: &ActorSystem) -> Self { + let (sender, replies) = channel(); + let inbox = actor_system + .actor_of_args::("world-inbox", sender) + .expect("failed to create the Picard world inbox actor"); + Self { + inbox, + replies, + next_request: Cell::new(1), + outstanding: RefCell::new(HashMap::new()), + } + } + + /// Starts a request whose replies can be collected with [`Mailbox::collect`]. + pub fn request(&self) -> RequestId { + let request = self.next_request.get(); + self.next_request.set(request + 1); + self.outstanding.borrow_mut().insert(request, Vec::new()); + request + } + + pub fn reply_to(&self) -> Sender { + Some(self.inbox.clone().into()) + } + + /// Waits for `expected` replies to `request`. Replies to unknown requests are dropped. + pub fn collect(&self, request: RequestId, expected: usize) -> Vec { + loop { + { + let mut outstanding = self.outstanding.borrow_mut(); + let received = outstanding + .get(&request) + .expect("collected a request that was not started") + .len(); + if received >= expected { + return outstanding.remove(&request).unwrap_or_default(); + } + } + match self.replies.recv_timeout(REPLY_TIMEOUT) { + Ok(reply) => { + if let Some(bodies) = self.outstanding.borrow_mut().get_mut(&reply.request) { + bodies.push(reply.body); + } + } + Err(error) => { + self.outstanding.borrow_mut().remove(&request); + panic!("Picard entity actors did not answer request {request}: {error}"); + } + } + } + } + + /// Sends one request and waits for its single reply. + pub fn ask(&self, send: impl FnOnce(RequestId, Sender)) -> ReplyBody { + let request = self.request(); + send(request, self.reply_to()); + self.collect(request, 1) + .pop() + .expect("collect returns the expected number of replies") + } +}