From 774b812bcddce51e952297e2750c7a07444a9869 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Wed, 30 Sep 2026 12:26:37 +0200 Subject: [PATCH] add: simulation core --- .dockerignore | 3 +- Cargo.lock | 36 ++ Cargo.toml | 5 + Dockerfile | 1 + README.md | 14 + default-core/Cargo.lock | 391 +++++++++++++++++++ default-core/Cargo.toml | 16 + src/simulation.rs => default-core/src/lib.rs | 163 +++++++- src/core_runtime.rs | 59 +++ src/lib.rs | 3 +- src/server.rs | 264 ++++++++++++- src/wasm_agent.rs | 2 +- 12 files changed, 936 insertions(+), 21 deletions(-) create mode 100644 default-core/Cargo.lock create mode 100644 default-core/Cargo.toml rename src/simulation.rs => default-core/src/lib.rs (66%) create mode 100644 src/core_runtime.rs diff --git a/.dockerignore b/.dockerignore index 704776e..81573a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ .github target data -example-wasm-agent/target \ No newline at end of file +example-wasm-agent/target +default-core/target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c65aceb..8b89b70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -694,6 +694,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "default-core" +version = "0.1.0" +dependencies = [ + "extism-pdk", + "serde", + "serde_json", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -870,6 +879,32 @@ dependencies = [ "serde_json", ] +[[package]] +name = "extism-pdk" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" +dependencies = [ + "anyhow", + "base64 0.22.1", + "extism-convert", + "extism-manifest", + "extism-pdk-derive", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -3974,6 +4009,7 @@ dependencies = [ "axum", "base64 0.22.1", "clap", + "default-core", "extism", "rcgen", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 7d1b39f..c3b73d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,10 +3,15 @@ name = "wolloseum" version = "0.1.0" edition = "2024" +[workspace] +members = ["default-core"] +resolver = "3" + [dependencies] axum = { version = "0.8", features = ["ws"] } base64 = "0.22" clap = { version = "4.6.7", features = ["derive", "env"] } +default-core = { path = "default-core" } extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] } rcgen = "0.14" reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] } diff --git a/Dockerfile b/Dockerfile index cae2af1..857ea89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM rust:1-bookworm AS builder WORKDIR /build COPY Cargo.toml Cargo.lock ./ +COPY default-core ./default-core COPY src ./src COPY web ./web RUN cargo build --release --bin wolloseum diff --git a/README.md b/README.md index 5eb82c3..c608858 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,20 @@ Agent and core WASM uploads are limited to 1 MiB and validated in the sandboxed runtime before they are persisted. Existing signed request headers remain accepted for compatibility with older clients. +The server uses the native Rust implementation in `default-core/` until a core +is uploaded. An uploaded core replaces it and must export an Extism `tick` +function that accepts `CoreTickInput` JSON (`world` and `actions`) and returns +`CoreTickOutput` JSON (`world`). Build the default implementation as a WASM +core with: + +```sh +cargo build --manifest-path default-core/Cargo.toml \ + --target wasm32-unknown-unknown --release +``` + +Upload `target/wasm32-unknown-unknown/release/default_core.wasm` +from the admin console to run that same implementation through the WASM path. + ## CLI The binary also provides an authenticated client. New installations use the diff --git a/default-core/Cargo.lock b/default-core/Cargo.lock new file mode 100644 index 0000000..84dc72c --- /dev/null +++ b/default-core/Cargo.lock @@ -0,0 +1,391 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "default-core" +version = "0.1.0" +dependencies = [ + "extism-pdk", + "serde", + "serde_json", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extism-convert" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19858c4c462309a8f3a20abec53e8603bda1eefda26c8bfab51d5516b40cbb" +dependencies = [ + "anyhow", + "base64", + "bytemuck", + "extism-convert-macros", + "prost", + "rmp-serde", + "serde", + "serde_json", +] + +[[package]] +name = "extism-convert-macros" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2932799f6d9f9646f97b65287f6bb2addc75a0ee61e40fb24559a7540dd928" +dependencies = [ + "manyhow", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "extism-manifest" +version = "1.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f59c8dadb5e0bde9a48c6ed45312e6ef625cbcd5f67c28459dbc8fe8bc0383" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" +dependencies = [ + "anyhow", + "base64", + "extism-convert", + "extism-manifest", + "extism-pdk-derive", + "serde", + "serde_json", +] + +[[package]] +name = "extism-pdk-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/default-core/Cargo.toml b/default-core/Cargo.toml new file mode 100644 index 0000000..8a4d1dc --- /dev/null +++ b/default-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "default-core" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +extism-pdk = "1" + +[dev-dependencies] +serde_json = "1" diff --git a/src/simulation.rs b/default-core/src/lib.rs similarity index 66% rename from src/simulation.rs rename to default-core/src/lib.rs index 97d32af..82fbc48 100644 --- a/src/simulation.rs +++ b/default-core/src/lib.rs @@ -2,9 +2,11 @@ use std::collections::BTreeMap; use std::error::Error; use std::fmt; +use serde::{Deserialize, Serialize}; + const MAX_WORLD_CELLS: usize = 1_048_576; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum Terrain { Grass, Water, @@ -12,7 +14,7 @@ pub enum Terrain { } #[repr(i32)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum ObservedTerrain { Grass = 0, Water = 1, @@ -20,10 +22,10 @@ pub enum ObservedTerrain { Boundary = 3, } -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub struct AgentId(pub u64); -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum Direction { North, East, @@ -31,13 +33,13 @@ pub enum Direction { West, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum Action { Move(Direction), Wait, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct AgentObservation { pub tick: u64, pub x: u16, @@ -45,7 +47,13 @@ pub struct AgentObservation { pub adjacent: [ObservedTerrain; 4], } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct AgentAction { + pub id: AgentId, + pub action: Action, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Agent { id: AgentId, x: u16, @@ -62,10 +70,11 @@ impl Agent { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub enum SimulationError { InvalidDimensions, WorldTooLarge, + InvalidWorldState, AgentAlreadyExists(AgentId), NoSpawnCellAvailable, UnknownAgent(AgentId), @@ -77,6 +86,7 @@ impl fmt::Display for SimulationError { match self { Self::InvalidDimensions => write!(formatter, "world dimensions must be nonzero"), Self::WorldTooLarge => write!(formatter, "world exceeds the cell limit"), + Self::InvalidWorldState => write!(formatter, "world state is invalid"), Self::AgentAlreadyExists(id) => write!(formatter, "agent {} already exists", id.0), Self::NoSpawnCellAvailable => { write!(formatter, "no unoccupied grass cell is available") @@ -89,7 +99,7 @@ impl fmt::Display for SimulationError { impl Error for SimulationError {} -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct World { seed: u64, width: u16, @@ -211,6 +221,10 @@ impl World { Ok(()) } + pub fn remove_agent(&mut self, id: AgentId) -> bool { + self.agents.remove(&id).is_some() + } + pub fn step(&mut self, actions: &BTreeMap) -> Result<(), SimulationError> { let next_tick = self .tick @@ -265,6 +279,26 @@ impl World { Ok(()) } + pub fn validate(&self) -> Result<(), SimulationError> { + let cell_count = usize::from(self.width) + .checked_mul(usize::from(self.height)) + .filter(|count| *count > 0 && *count <= MAX_WORLD_CELLS) + .ok_or(SimulationError::InvalidWorldState)?; + if self.tiles.len() != cell_count || self.spawn_cursor >= cell_count { + return Err(SimulationError::InvalidWorldState); + } + let mut occupied = BTreeMap::new(); + for (id, agent) in &self.agents { + if *id != agent.id || self.terrain_at(agent.x, agent.y) != Some(Terrain::Grass) { + return Err(SimulationError::InvalidWorldState); + } + if occupied.insert(agent.position(), ()).is_some() { + return Err(SimulationError::InvalidWorldState); + } + } + Ok(()) + } + fn index(&self, x: u16, y: u16) -> Option { if x >= self.width || y >= self.height { return None; @@ -286,6 +320,52 @@ impl World { } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CoreTickInput { + pub world: World, + pub actions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CoreTickOutput { + pub world: World, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CoreError { + DuplicateAction(AgentId), + Simulation(SimulationError), +} + +impl fmt::Display for CoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateAction(id) => write!(formatter, "duplicate action for agent {}", id.0), + Self::Simulation(error) => write!(formatter, "{error}"), + } + } +} + +impl Error for CoreError {} + +impl From for CoreError { + fn from(error: SimulationError) -> Self { + Self::Simulation(error) + } +} + +pub fn run_tick(mut input: CoreTickInput) -> Result { + input.world.validate()?; + let mut actions = BTreeMap::new(); + for action in input.actions { + if actions.insert(action.id, action.action).is_some() { + return Err(CoreError::DuplicateAction(action.id)); + } + } + input.world.step(&actions)?; + Ok(CoreTickOutput { world: input.world }) +} + struct SplitMix64 { state: u64, } @@ -304,9 +384,23 @@ impl SplitMix64 { } } +#[cfg(target_arch = "wasm32")] +use extism_pdk::*; + +#[cfg(target_arch = "wasm32")] +#[plugin_fn] +pub fn tick(input: Json) -> FnResult> { + run_tick(input.0) + .map(Json) + .map_err(|error| extism_pdk::Error::msg(error.to_string()).into()) +} + #[cfg(test)] mod tests { - use super::{Action, AgentId, Direction, SimulationError, World}; + use super::{ + Action, AgentAction, AgentId, CoreError, CoreTickInput, Direction, SimulationError, World, + run_tick, + }; use std::collections::BTreeMap; #[test] @@ -339,16 +433,59 @@ mod tests { } #[test] - fn invalid_action_does_not_partially_advance_world() { + fn native_tick_advances_serializable_world() { let mut world = World::generate(42, 8, 8).unwrap(); + let id = AgentId(7); + world.spawn_agent(id).unwrap(); + let input = CoreTickInput { + world, + actions: vec![AgentAction { + id, + action: Action::Wait, + }], + }; + + let encoded = serde_json::to_vec(&input).unwrap(); + let decoded = serde_json::from_slice(&encoded).unwrap(); + let output = run_tick(decoded).unwrap(); + assert_eq!(output.world.tick(), 1); + assert_eq!(output.world.agents().count(), 1); + } + + #[test] + fn duplicate_actions_are_rejected() { + let mut world = World::generate(42, 8, 8).unwrap(); + let id = AgentId(7); + world.spawn_agent(id).unwrap(); + let input = CoreTickInput { + world, + actions: vec![ + AgentAction { + id, + action: Action::Wait, + }, + AgentAction { + id, + action: Action::Wait, + }, + ], + }; + + assert_eq!(run_tick(input), Err(CoreError::DuplicateAction(id))); + } + + #[test] + fn invalid_action_does_not_advance_world() { + let world = World::generate(42, 8, 8).unwrap(); let before = world.clone(); let actions = BTreeMap::from([(AgentId(404), Action::Wait)]); + let mut next = world; assert_eq!( - world.step(&actions), + next.step(&actions), Err(SimulationError::UnknownAgent(AgentId(404))) ); - assert_eq!(world, before); + assert_eq!(next, before); } #[test] diff --git a/src/core_runtime.rs b/src/core_runtime.rs new file mode 100644 index 0000000..91a2790 --- /dev/null +++ b/src/core_runtime.rs @@ -0,0 +1,59 @@ +use default_core::{CoreTickInput, CoreTickOutput, World, run_tick}; +use extism::{Manifest, Plugin, PluginBuilder, Wasm}; + +const CORE_FUEL_LIMIT: u64 = 5_000_000; +const CORE_MEMORY_PAGES: u32 = 64; + +pub(crate) enum CoreRuntime { + Native, + Wasm(Plugin), +} + +impl CoreRuntime { + pub(crate) fn from_wasm(bytes: &[u8]) -> Result { + let manifest = + Manifest::new([Wasm::data(bytes.to_vec())]).with_memory_max(CORE_MEMORY_PAGES); + let plugin = PluginBuilder::new(manifest) + .with_wasi(false) + .with_fuel_limit(CORE_FUEL_LIMIT) + .build() + .map_err(|error| error.to_string())?; + let mut runtime = Self::Wasm(plugin); + runtime.tick(CoreTickInput { + world: World::generate(0x574F4C4C, 8, 8).map_err(|error| error.to_string())?, + actions: Vec::new(), + })?; + Ok(runtime) + } + + pub(crate) fn tick(&mut self, input: CoreTickInput) -> Result { + let expected_tick = input + .world + .tick() + .checked_add(1) + .ok_or_else(|| "simulation tick overflow".to_string())?; + let expected_seed = input.world.seed(); + let expected_dimensions = input.world.dimensions(); + let output = match self { + Self::Native => run_tick(input).map_err(|error| error.to_string())?, + Self::Wasm(plugin) => { + let encoded = serde_json::to_vec(&input).map_err(|error| error.to_string())?; + let response = plugin + .call("tick", &encoded) + .map_err(|error| error.to_string())?; + serde_json::from_slice(response).map_err(|error| error.to_string())? + } + }; + output + .world + .validate() + .map_err(|error| format!("core returned invalid world state: {error}"))?; + if output.world.tick() != expected_tick + || output.world.seed() != expected_seed + || output.world.dimensions() != expected_dimensions + { + return Err("core returned incompatible world state".to_string()); + } + Ok(output) + } +} diff --git a/src/lib.rs b/src/lib.rs index 7f8316a..1af9d55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod cli; +mod core_runtime; pub mod server; -pub mod simulation; +pub use default_core as simulation; pub mod wasm_agent; diff --git a/src/server.rs b/src/server.rs index 32db904..a1bb992 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,11 +1,13 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fs::{self, OpenOptions}; use std::io::Write; use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::body::Bytes; use axum::extract::State; @@ -16,6 +18,9 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use base64::Engine as _; use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD}; +use default_core::{ + Action, AgentAction, AgentId, AgentObservation, CoreTickInput, ObservedTerrain, World, +}; use rcgen::{KeyPair, PKCS_ED25519}; use ring::rand::{SecureRandom, SystemRandom}; use ring::signature::{ED25519, UnparsedPublicKey}; @@ -23,7 +28,7 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; -use crate::simulation::{AgentObservation, ObservedTerrain}; +use crate::core_runtime::CoreRuntime; use crate::wasm_agent::WasmAgent; const MAX_WASM_BYTES: usize = 1_048_576; @@ -200,10 +205,252 @@ fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> { struct AppState { database: Arc>, game_running: Arc, + simulation_tick: Arc, event_sender: broadcast::Sender, event_sequence: Arc>, } +struct StoredAgent { + client_id: String, + wasm: Vec, + sha256: String, +} + +struct StoredCore { + wasm: Vec, + sha256: String, +} + +struct ActiveAgent { + id: AgentId, + sha256: String, + runtime: WasmAgent, +} + +struct SimulationLoop { + world: World, + agents: BTreeMap, + next_agent_id: u64, + core: Option, + core_sha256: Option, +} + +impl SimulationLoop { + fn new() -> Self { + eprintln!("Using native Rust simulation core"); + Self { + world: World::generate(0x574F4C4C, 32, 32) + .expect("the default world dimensions are valid"), + agents: BTreeMap::new(), + next_agent_id: 1, + core: Some(CoreRuntime::Native), + core_sha256: None, + } + } + + fn select_core(&mut self, uploaded: Option) { + match uploaded { + Some(core) if self.core_sha256.as_deref() != Some(&core.sha256) => { + self.core_sha256 = Some(core.sha256.clone()); + match CoreRuntime::from_wasm(&core.wasm) { + Ok(runtime) => { + eprintln!("Using uploaded simulation core {}", core.sha256); + self.core = Some(runtime); + } + Err(error) => { + self.core = None; + eprintln!("Uploaded simulation core could not load: {error}"); + } + } + } + None if self.core_sha256.is_some() => { + self.core_sha256 = None; + self.core = Some(CoreRuntime::Native); + } + _ => {} + } + } + + fn sync_agents(&mut self, stored_agents: Vec) { + let current_ids: BTreeSet<_> = stored_agents + .iter() + .map(|agent| agent.client_id.as_str()) + .collect(); + let removed: Vec<_> = self + .agents + .keys() + .filter(|client_id| !current_ids.contains(client_id.as_str())) + .cloned() + .collect(); + for client_id in removed { + if let Some(agent) = self.agents.remove(&client_id) { + self.world.remove_agent(agent.id); + } + } + + for stored in stored_agents { + if self + .agents + .get(&stored.client_id) + .is_some_and(|agent| agent.sha256 == stored.sha256) + { + continue; + } + let runtime = match WasmAgent::load(&stored.wasm) { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("Agent {} could not load: {error}", stored.client_id); + continue; + } + }; + + if let Some(agent) = self.agents.get_mut(&stored.client_id) { + agent.sha256 = stored.sha256; + agent.runtime = runtime; + continue; + } + + let id = AgentId(self.next_agent_id); + if let Err(error) = self.world.spawn_agent(id) { + eprintln!("Agent {} could not spawn: {error}", stored.client_id); + continue; + } + self.next_agent_id = self.next_agent_id.saturating_add(1); + self.agents.insert( + stored.client_id, + ActiveAgent { + id, + sha256: stored.sha256, + runtime, + }, + ); + } + } + + fn step(&mut self) -> Result<(), String> { + let actions = self + .agents + .values_mut() + .map(|agent| { + let action = match self.world.observation(agent.id) { + Ok(observation) => agent.runtime.decide(observation).unwrap_or_else(|error| { + eprintln!("Agent {} decision failed: {error}", agent.id.0); + Action::Wait + }), + Err(error) => { + eprintln!("Agent {} observation failed: {error}", agent.id.0); + Action::Wait + } + }; + AgentAction { + id: agent.id, + action, + } + }) + .collect(); + let input = CoreTickInput { + world: self.world.clone(), + actions, + }; + let output = self + .core + .as_mut() + .ok_or_else(|| "uploaded simulation core is unavailable".to_string())? + .tick(input); + match output { + Ok(output) => self.world = output.world, + Err(error) => { + if self.core_sha256.is_some() { + self.core = None; + } + return Err(error); + } + } + Ok(()) + } +} + +fn run_simulation_loop(state: AppState) { + let mut simulation = SimulationLoop::new(); + loop { + thread::sleep(Duration::from_millis(100)); + if !state.game_running.load(Ordering::SeqCst) { + continue; + } + + let (agents, core) = match load_simulation_inputs(&state) { + Ok(inputs) => inputs, + Err(error) => { + eprintln!("Could not load simulation inputs: {error}"); + continue; + } + }; + simulation.select_core(core); + simulation.sync_agents(agents); + if simulation.core.is_none() { + continue; + } + if let Err(error) = simulation.step() { + eprintln!("Simulation tick failed: {error}"); + continue; + } + + let tick = simulation.world.tick(); + state.simulation_tick.store(tick, Ordering::SeqCst); + let agents = simulation + .world + .agents() + .map(|agent| { + let (x, y) = agent.position(); + serde_json::json!({ "id": agent.id().0, "x": x, "y": y }) + }) + .collect::>(); + publish_state_event( + &state, + "simulation_tick", + serde_json::json!({ "tick": tick, "agents": agents }), + ); + } +} + +fn load_simulation_inputs( + state: &AppState, +) -> Result<(Vec, Option), String> { + let database = state.database.lock().map_err(|error| error.to_string())?; + let mut statement = database + .prepare( + "SELECT agents.identity_id, agents.wasm, agents.sha256 + FROM agents JOIN identities ON identities.id = agents.identity_id + WHERE identities.active = 1 ORDER BY agents.identity_id", + ) + .map_err(|error| error.to_string())?; + let agents = statement + .query_map([], |row| { + Ok(StoredAgent { + client_id: row.get(0)?, + wasm: row.get(1)?, + sha256: row.get(2)?, + }) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let core = database + .query_row( + "SELECT wasm, sha256 FROM core_module WHERE id = 1", + [], + |row| { + Ok(StoredCore { + wasm: row.get(0)?, + sha256: row.get(1)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())?; + Ok((agents, core)) +} + #[derive(Clone, Debug, Serialize)] struct StateEvent { sequence: u64, @@ -271,9 +518,14 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box let state = AppState { database: Arc::new(Mutex::new(database)), game_running: Arc::new(AtomicBool::new(true)), + simulation_tick: Arc::new(AtomicU64::new(0)), event_sender: broadcast::channel(256).0, event_sequence: Arc::new(Mutex::new(0)), }; + let simulation_state = state.clone(); + thread::Builder::new() + .name("wolloseum-simulation".to_string()) + .spawn(move || run_simulation_loop(simulation_state))?; let app = Router::new() .route("/", get(user_ui)) .route("/admin", get(admin_ui)) @@ -806,7 +1058,7 @@ async fn upload_core( )); } // Sandboxed load validates the module without requiring agent-specific exports. - WasmAgent::load(&body).map_err(|error| ApiError::bad_request(error.to_string()))?; + CoreRuntime::from_wasm(&body).map_err(ApiError::bad_request)?; let digest = ring::digest::digest(&ring::digest::SHA256, &body); let sha256 = digest @@ -905,6 +1157,7 @@ fn snapshot_event(state: &AppState) -> StateEvent { event: "snapshot".to_string(), data: serde_json::json!({ "running": state.game_running.load(Ordering::SeqCst), + "tick": state.simulation_tick.load(Ordering::SeqCst), "sequence": sequence, }), } @@ -1055,7 +1308,7 @@ mod tests { use ring::signature::Ed25519KeyPair; use std::fs; use std::path::Path; - use std::sync::atomic::AtomicBool; + use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Arc, Mutex}; fn test_state(directory: &Path) -> (AppState, String, KeyPair) { @@ -1072,6 +1325,7 @@ mod tests { let state = AppState { database: Arc::new(Mutex::new(database)), game_running: Arc::new(AtomicBool::new(true)), + simulation_tick: Arc::new(AtomicU64::new(0)), event_sender: tokio::sync::broadcast::channel(256).0, event_sequence: Arc::new(Mutex::new(0)), }; diff --git a/src/wasm_agent.rs b/src/wasm_agent.rs index 1a9fbce..0818021 100644 --- a/src/wasm_agent.rs +++ b/src/wasm_agent.rs @@ -4,7 +4,7 @@ use std::fmt; use extism::{Manifest, Plugin, PluginBuilder, Wasm}; use serde::{Deserialize, Serialize}; -use crate::simulation::{Action, AgentObservation, Direction, ObservedTerrain}; +use default_core::{Action, AgentObservation, Direction, ObservedTerrain}; const MAX_MODULE_BYTES: usize = 1_048_576; const MAX_MEMORY_PAGES: u32 = 32;