refactor(world)!: make Picard actors own entity state

People, rabbits, wolves and partnerships are now Picard actors that own
their state and apply their own rules (ageing, hunger and meals,
starvation, energy, breeding costs, death, birth intervals). The world
no longer keeps mirrored Vecs that were read from and written back to
generic entity actors every step; it only reaches entities through
typed messages and replies, correlated by request id through a single
inbox actor.

World-level state (terrain, resources, stockpile, houses, calendar, the
world random stream) stays in the world, and behaviour decisions stay
on the simulation thread because the Rune VM is not Send. Replies are
processed in entity order and each person has its own persisted random
stream, so runs and snapshot restores stay deterministic.

Rabbits killed by hunters no longer move or breed later in the same
tick.

BREAKING CHANGE: Picard actors are named person-<id>, rabbit-<id>,
wolf-<id> and partnership-<id> instead of entity-<id>, which changes
the paths used with the actor console.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-12 16:47:44 +02:00
parent 21f66981fa
commit 0009206a3c
4 changed files with 1546 additions and 859 deletions

View File

@ -7,3 +7,9 @@ Notable changes to this project are documented here.
### Added ### Added
- Expose the Picard actor console over the `/api/actors/ws` WebSocket endpoint. - 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-<id>`, `rabbit-<id>`, `wolf-<id>`, and `partnership-<id>` instead of `entity-<id>`.
- Rabbits killed by hunters no longer move or breed later in the same tick.
- Update Picard to `054449c`.

View File

@ -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. 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-<id>`, `rabbit-<id>`, `wolf-<id>`, and `partnership-<id>`). 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. 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.

File diff suppressed because it is too large Load Diff

496
src/world/actors.rs Normal file
View File

@ -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<u32>),
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<f32>,
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<f32>,
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<LifeStage>),
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<Person> 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<LifeStage> {
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<Self::Msg>, 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<Self::Msg>, 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<Partnership> for PartnershipActor {
fn create_args(partnership: Partnership) -> Self {
Self { partnership }
}
}
impl Actor for PartnershipActor {
type Msg = PartnershipMsg;
fn recv(&mut self, _ctx: &ActorContext<Self::Msg>, 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<Reply>,
}
impl ActorFactoryArgs<MpscSender<Reply>> for InboxActor {
fn create_args(replies: MpscSender<Reply>) -> Self {
Self { replies }
}
}
impl Actor for InboxActor {
type Msg = Reply;
fn recv(&mut self, _ctx: &ActorContext<Self::Msg>, 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<Reply>,
replies: Receiver<Reply>,
next_request: Cell<RequestId>,
outstanding: RefCell<HashMap<RequestId, Vec<ReplyBody>>>,
}
impl Mailbox {
pub fn new(actor_system: &ActorSystem) -> Self {
let (sender, replies) = channel();
let inbox = actor_system
.actor_of_args::<InboxActor, _>("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<ReplyBody> {
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")
}
}