diff --git a/Cargo.lock b/Cargo.lock index 38855b3..5b15f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1939,7 +1939,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "picard" version = "0.4.2" -source = "git+https://github.com/picard-rs/picard?rev=6186d05b46b432cecf1ca1e0686088de08ef5640#6186d05b46b432cecf1ca1e0686088de08ef5640" +source = "git+https://github.com/picard-rs/picard?rev=89939c33c08433111493fe16fcb535ebdb53d79b#89939c33c08433111493fe16fcb535ebdb53d79b" dependencies = [ "better_default", "chrono", @@ -1952,11 +1952,13 @@ dependencies = [ "picard-macros", "rand 0.9.5", "serde", + "serde_json", "slog", "slog-scope", "slog-stdlog", "thiserror 2.0.21", "tracing", + "tungstenite", "uuid", "wasm-bindgen-futures", "web-time", @@ -1965,7 +1967,7 @@ dependencies = [ [[package]] name = "picard-macros" version = "0.2.0" -source = "git+https://github.com/picard-rs/picard?rev=6186d05b46b432cecf1ca1e0686088de08ef5640#6186d05b46b432cecf1ca1e0686088de08ef5640" +source = "git+https://github.com/picard-rs/picard?rev=89939c33c08433111493fe16fcb535ebdb53d79b#89939c33c08433111493fe16fcb535ebdb53d79b" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 3bac44e..e74f36b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,11 @@ edition = "2024" [workspace] members = ["ui"] +[features] +default = ["console"] +console = ["picard/console", "serde"] +serde = ["picard/serde"] + [dependencies] rune = "0.14.2" axum = { version = "0.8", features = ["ws"] } @@ -15,7 +20,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "any", "sqlite", "postgres"] } rand = "0.8" -picard = { git = "https://github.com/picard-rs/picard", rev = "6186d05b46b432cecf1ca1e0686088de08ef5640", features = [ +picard = { git = "https://github.com/picard-rs/picard", rev = "89939c33c08433111493fe16fcb535ebdb53d79b", features = [ "stats", "tracing", ] } diff --git a/README.md b/README.md index 981ac1a..b49616a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A browser-based world simulation with a Rust server and a Leptos/Wasm UI. The se Living simulation entities (people, rabbits, and wolves) run as Picard actors. The world coordinates terrain, shared resources, and actor messages; Rune behavior scripts remain on the simulation thread because Rune's VM is not `Send`. Picard's tracing and runtime statistics features are enabled: actor lifecycle events are logged (configure verbosity with `RUST_LOG`, for example `RUST_LOG=picard=trace`), and cumulative actor/message counters are shown in the UI. +The 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 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 in-browser script editor edits the same file contents. Applied edits take effect immediately for behaviours and tuning values, while map dimensions, initial population, and calendar defaults apply when the world is reset. Editor changes are not written back to disk: on restart, `config.rn` from disk replaces them, even when a saved world snapshot is restored. diff --git a/src/main.rs b/src/main.rs index d5a5f29..d4a30c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,8 @@ catch (e) { document.body.textContent = 'Failed to load the UI: ' + e; } enum Msg { Edit(String), Script(String, oneshot::Sender>), + #[cfg(feature = "console")] + PicardConsole(String, oneshot::Sender), Restart { regenerate: bool, reply: oneshot::Sender, @@ -86,6 +88,10 @@ fn run_sim( } let _ = reply.send(r); } + #[cfg(feature = "console")] + Msg::PicardConsole(request, reply) => { + let _ = reply.send(world.handle_picard_console_text(&request)); + } Msg::Restart { regenerate, reply } => { let seed = if regenerate { world.regenerated_seed() @@ -215,6 +221,53 @@ async fn ws(upgrade: WebSocketUpgrade, State(app): State) -> Response { upgrade.on_upgrade(move |socket| client(socket, app)) } +#[cfg(feature = "console")] +async fn picard_ws(upgrade: WebSocketUpgrade, State(app): State) -> Response { + upgrade.on_upgrade(move |socket| picard_client(socket, app)) +} + +#[cfg(feature = "console")] +async fn picard_client(mut socket: WebSocket, app: App) { + while let Some(message) = socket.recv().await { + match message { + Ok(Message::Text(text)) if text.len() <= picard::console::MAX_REQUEST_BYTES => { + let (reply, result) = oneshot::channel(); + if app + .tx + .send(Msg::PicardConsole(text.to_string(), reply)) + .is_err() + { + return; + } + let response = match tokio::time::timeout(Duration::from_secs(5), result).await { + Ok(Ok(response)) => response, + Ok(Err(_)) | Err(_) => { + r#"{"id":null,"error":{"code":"unavailable","message":"Picard console did not respond"}}"# + .to_owned() + } + }; + if socket.send(Message::Text(response.into())).await.is_err() { + return; + } + } + Ok(Message::Text(_)) => { + if socket + .send(Message::Text( + r#"{"id":null,"error":{"code":"invalid_request","message":"Request exceeds 65536 bytes"}}"# + .into(), + )) + .await + .is_err() + { + return; + } + } + Ok(Message::Close(_)) | Err(_) | Ok(Message::Binary(_)) => return, + Ok(Message::Ping(_) | Message::Pong(_)) => {} + } + } +} + /// Pushes every new snapshot to the client; text frames from the client are edit commands. async fn client(mut socket: WebSocket, app: App) { let mut rx = app.state.clone(); @@ -332,7 +385,10 @@ async fn main() { .route("/api/regenerate", post(post_regenerate)) .route("/api/edit", post(post_edit)) .route("/ws", get(ws)) - .route("/pkg/{*path}", get(pkg)) + .route("/pkg/{*path}", get(pkg)); + #[cfg(feature = "console")] + let router = router.route("/api/actors/ws", get(picard_ws)); + let router = router .layer(DefaultBodyLimit::max(65536)) .layer(middleware::from_fn(same_origin)) .with_state(app); diff --git a/src/world.rs b/src/world.rs index db3fb05..d433621 100644 --- a/src/world.rs +++ b/src/world.rs @@ -11,7 +11,8 @@ use std::collections::{BinaryHeap, HashMap, HashSet}; use std::fmt::Write; use std::sync::{ Arc, OnceLock, - mpsc::{SyncSender, sync_channel}, + atomic::{AtomicU64, Ordering}, + mpsc::{Receiver, Sender as MpscSender, channel}, }; use std::time::Duration; @@ -893,7 +894,7 @@ pub struct Animal { dead: bool, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] enum EntityKind { Person(Person), Rabbit(Animal), @@ -912,22 +913,42 @@ struct Partnership { actor_id: u64, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] struct EntityState { id: u64, kind: EntityKind, } -#[derive(Debug)] +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, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] enum EntityMessage { - Read(SyncSender), - Write(EntityState, SyncSender<()>), + 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 } @@ -937,19 +958,47 @@ impl ActorFactoryArgs for EntityActor { impl Actor for EntityActor { type Msg = EntityMessage; - fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, _sender: Sender) { + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { match msg { - EntityMessage::Read(reply) => { - let _ = reply.send(self.state.clone()); + 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(state, reply) => { + EntityMessage::Write(mut state) => { + state.restore_actor_id(); self.state = state; - let _ = reply.send(()); + 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); + pub struct World { seed: u64, width: usize, @@ -1201,29 +1250,49 @@ impl World { w } - fn read_entities(actors: &HashMap>) -> Vec { - let replies: Vec<_> = actors - .values() - .map(|actor| { - let (reply, result) = sync_channel(1); - actor.tell(EntityMessage::Read(reply), None); - result - }) - .collect(); - let mut states: Vec<_> = replies - .into_iter() - .map(|reply| { - reply - .recv_timeout(Duration::from_secs(5)) - .expect("Picard entity actor did not respond to read") - }) - .collect(); + 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 read_entities( + actor_system: &ActorSystem, + actors: &HashMap>, + ) -> Vec { + if actors.is_empty() { + return Vec::new(); + } + let (reply_actor, replies) = Self::create_reply_actor(actor_system); + for actor in actors.values() { + actor.tell(EntityMessage::Read, Some(reply_actor.clone().into())); + } + 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 } fn load_entities(&mut self) { - let states = Self::read_entities(&self.entities); + let states = Self::read_entities(&self.actor_system, &self.entities); self.people.clear(); self.rabbits.clear(); self.wolves.clear(); @@ -1258,7 +1327,7 @@ impl World { } } - let states = self + let states: Vec<_> = self .people .iter() .cloned() @@ -1282,16 +1351,32 @@ impl World { 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) { - let (reply, result) = sync_channel(1); - actor.tell(EntityMessage::Write(state, reply), None); - result - .recv_timeout(Duration::from_secs(5)) - .expect("Picard entity actor did not respond to write"); + 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}"); @@ -1302,6 +1387,30 @@ impl World { self.entities.insert(id, actor); } } + 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}"); + } + } + } + } + if let Some(reply_actor) = reply_actor { + reply_actor.stop(); + } self.entities.retain(|id, actor| { if active.contains(id) { true @@ -2685,6 +2794,11 @@ impl World { ); s } + + #[cfg(feature = "console")] + pub fn handle_picard_console_text(&self, request: &str) -> String { + self.actor_system.console().handle_text(request) + } } impl Drop for World { @@ -2696,7 +2810,10 @@ impl Drop for World { #[cfg(test)] mod tests { - use super::{DEFAULT_SCRIPT, H, LifeStage, SimScript, W, World, cheb, default_config}; + use super::{ + DEFAULT_SCRIPT, EntityKind, EntityMessage, EntityState, H, LifeStage, Partnership, + SimScript, W, World, cheb, default_config, + }; fn simulation_state(world: &World) -> serde_json::Value { let mut state: serde_json::Value = serde_json::from_str(&world.state_json()).unwrap(); @@ -2972,6 +3089,31 @@ mod tests { assert_ne!(world.regenerated_seed(), world.seed()); } + #[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, + }), + }); + 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"); + }; + restored.restore_actor_id(); + + let EntityKind::Partnership(partnership) = restored.kind else { + panic!("serialized entity write message changed entity kinds"); + }; + assert_eq!(partnership.actor_id, 7); + } + #[test] fn picard_actors_own_living_entity_state() { let mut world = World::new(42);