add: simulation core

This commit is contained in:
Steve Biedermann 2026-09-30 12:26:37 +02:00
parent 8461085d8b
commit 774b812bcd
12 changed files with 936 additions and 21 deletions

View File

@ -3,3 +3,4 @@
target target
data data
example-wasm-agent/target example-wasm-agent/target
default-core/target

36
Cargo.lock generated
View File

@ -694,6 +694,15 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "default-core"
version = "0.1.0"
dependencies = [
"extism-pdk",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "der-parser" name = "der-parser"
version = "10.0.0" version = "10.0.0"
@ -870,6 +879,32 @@ dependencies = [
"serde_json", "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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@ -3974,6 +4009,7 @@ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"clap", "clap",
"default-core",
"extism", "extism",
"rcgen", "rcgen",
"reqwest", "reqwest",

View File

@ -3,10 +3,15 @@ name = "wolloseum"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[workspace]
members = ["default-core"]
resolver = "3"
[dependencies] [dependencies]
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
base64 = "0.22" base64 = "0.22"
clap = { version = "4.6.7", features = ["derive", "env"] } clap = { version = "4.6.7", features = ["derive", "env"] }
default-core = { path = "default-core" }
extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] } extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] }
rcgen = "0.14" rcgen = "0.14"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] } reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"] }

View File

@ -2,6 +2,7 @@ FROM rust:1-bookworm AS builder
WORKDIR /build WORKDIR /build
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY default-core ./default-core
COPY src ./src COPY src ./src
COPY web ./web COPY web ./web
RUN cargo build --release --bin wolloseum RUN cargo build --release --bin wolloseum

View File

@ -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 sandboxed runtime before they are persisted. Existing signed request headers
remain accepted for compatibility with older clients. 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 ## CLI
The binary also provides an authenticated client. New installations use the The binary also provides an authenticated client. New installations use the

391
default-core/Cargo.lock generated Normal file
View File

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

16
default-core/Cargo.toml Normal file
View File

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

View File

@ -2,9 +2,11 @@ use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
use serde::{Deserialize, Serialize};
const MAX_WORLD_CELLS: usize = 1_048_576; 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 { pub enum Terrain {
Grass, Grass,
Water, Water,
@ -12,7 +14,7 @@ pub enum Terrain {
} }
#[repr(i32)] #[repr(i32)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ObservedTerrain { pub enum ObservedTerrain {
Grass = 0, Grass = 0,
Water = 1, Water = 1,
@ -20,10 +22,10 @@ pub enum ObservedTerrain {
Boundary = 3, 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); pub struct AgentId(pub u64);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Direction { pub enum Direction {
North, North,
East, East,
@ -31,13 +33,13 @@ pub enum Direction {
West, West,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Action { pub enum Action {
Move(Direction), Move(Direction),
Wait, Wait,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AgentObservation { pub struct AgentObservation {
pub tick: u64, pub tick: u64,
pub x: u16, pub x: u16,
@ -45,7 +47,13 @@ pub struct AgentObservation {
pub adjacent: [ObservedTerrain; 4], 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 { pub struct Agent {
id: AgentId, id: AgentId,
x: u16, x: u16,
@ -62,10 +70,11 @@ impl Agent {
} }
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum SimulationError { pub enum SimulationError {
InvalidDimensions, InvalidDimensions,
WorldTooLarge, WorldTooLarge,
InvalidWorldState,
AgentAlreadyExists(AgentId), AgentAlreadyExists(AgentId),
NoSpawnCellAvailable, NoSpawnCellAvailable,
UnknownAgent(AgentId), UnknownAgent(AgentId),
@ -77,6 +86,7 @@ impl fmt::Display for SimulationError {
match self { match self {
Self::InvalidDimensions => write!(formatter, "world dimensions must be nonzero"), Self::InvalidDimensions => write!(formatter, "world dimensions must be nonzero"),
Self::WorldTooLarge => write!(formatter, "world exceeds the cell limit"), 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::AgentAlreadyExists(id) => write!(formatter, "agent {} already exists", id.0),
Self::NoSpawnCellAvailable => { Self::NoSpawnCellAvailable => {
write!(formatter, "no unoccupied grass cell is available") write!(formatter, "no unoccupied grass cell is available")
@ -89,7 +99,7 @@ impl fmt::Display for SimulationError {
impl Error for SimulationError {} impl Error for SimulationError {}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct World { pub struct World {
seed: u64, seed: u64,
width: u16, width: u16,
@ -211,6 +221,10 @@ impl World {
Ok(()) Ok(())
} }
pub fn remove_agent(&mut self, id: AgentId) -> bool {
self.agents.remove(&id).is_some()
}
pub fn step(&mut self, actions: &BTreeMap<AgentId, Action>) -> Result<(), SimulationError> { pub fn step(&mut self, actions: &BTreeMap<AgentId, Action>) -> Result<(), SimulationError> {
let next_tick = self let next_tick = self
.tick .tick
@ -265,6 +279,26 @@ impl World {
Ok(()) 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<usize> { fn index(&self, x: u16, y: u16) -> Option<usize> {
if x >= self.width || y >= self.height { if x >= self.width || y >= self.height {
return None; return None;
@ -286,6 +320,52 @@ impl World {
} }
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CoreTickInput {
pub world: World,
pub actions: Vec<AgentAction>,
}
#[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<SimulationError> for CoreError {
fn from(error: SimulationError) -> Self {
Self::Simulation(error)
}
}
pub fn run_tick(mut input: CoreTickInput) -> Result<CoreTickOutput, CoreError> {
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 { struct SplitMix64 {
state: u64, 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<CoreTickInput>) -> FnResult<Json<CoreTickOutput>> {
run_tick(input.0)
.map(Json)
.map_err(|error| extism_pdk::Error::msg(error.to_string()).into())
}
#[cfg(test)] #[cfg(test)]
mod tests { 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; use std::collections::BTreeMap;
#[test] #[test]
@ -339,16 +433,59 @@ mod tests {
} }
#[test] #[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 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 before = world.clone();
let actions = BTreeMap::from([(AgentId(404), Action::Wait)]); let actions = BTreeMap::from([(AgentId(404), Action::Wait)]);
let mut next = world;
assert_eq!( assert_eq!(
world.step(&actions), next.step(&actions),
Err(SimulationError::UnknownAgent(AgentId(404))) Err(SimulationError::UnknownAgent(AgentId(404)))
); );
assert_eq!(world, before); assert_eq!(next, before);
} }
#[test] #[test]

59
src/core_runtime.rs Normal file
View File

@ -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<Self, String> {
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<CoreTickOutput, String> {
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)
}
}

View File

@ -1,4 +1,5 @@
pub mod cli; pub mod cli;
mod core_runtime;
pub mod server; pub mod server;
pub mod simulation; pub use default_core as simulation;
pub mod wasm_agent; pub mod wasm_agent;

View File

@ -1,11 +1,13 @@
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error; use std::error::Error;
use std::fs::{self, OpenOptions}; use std::fs::{self, OpenOptions};
use std::io::Write; use std::io::Write;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; 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::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::body::Bytes;
use axum::extract::State; use axum::extract::State;
@ -16,6 +18,9 @@ use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use base64::Engine as _; use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD}; 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 rcgen::{KeyPair, PKCS_ED25519};
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, UnparsedPublicKey}; use ring::signature::{ED25519, UnparsedPublicKey};
@ -23,7 +28,7 @@ use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::simulation::{AgentObservation, ObservedTerrain}; use crate::core_runtime::CoreRuntime;
use crate::wasm_agent::WasmAgent; use crate::wasm_agent::WasmAgent;
const MAX_WASM_BYTES: usize = 1_048_576; const MAX_WASM_BYTES: usize = 1_048_576;
@ -200,10 +205,252 @@ fn bearer_token(headers: &HeaderMap) -> Result<&str, ApiError> {
struct AppState { struct AppState {
database: Arc<Mutex<Connection>>, database: Arc<Mutex<Connection>>,
game_running: Arc<AtomicBool>, game_running: Arc<AtomicBool>,
simulation_tick: Arc<AtomicU64>,
event_sender: broadcast::Sender<StateEvent>, event_sender: broadcast::Sender<StateEvent>,
event_sequence: Arc<Mutex<u64>>, event_sequence: Arc<Mutex<u64>>,
} }
struct StoredAgent {
client_id: String,
wasm: Vec<u8>,
sha256: String,
}
struct StoredCore {
wasm: Vec<u8>,
sha256: String,
}
struct ActiveAgent {
id: AgentId,
sha256: String,
runtime: WasmAgent,
}
struct SimulationLoop {
world: World,
agents: BTreeMap<String, ActiveAgent>,
next_agent_id: u64,
core: Option<CoreRuntime>,
core_sha256: Option<String>,
}
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<StoredCore>) {
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<StoredAgent>) {
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::<Vec<_>>();
publish_state_event(
&state,
"simulation_tick",
serde_json::json!({ "tick": tick, "agents": agents }),
);
}
}
fn load_simulation_inputs(
state: &AppState,
) -> Result<(Vec<StoredAgent>, Option<StoredCore>), 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::<Result<Vec<_>, _>>()
.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)] #[derive(Clone, Debug, Serialize)]
struct StateEvent { struct StateEvent {
sequence: u64, sequence: u64,
@ -271,9 +518,14 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box
let state = AppState { let state = AppState {
database: Arc::new(Mutex::new(database)), database: Arc::new(Mutex::new(database)),
game_running: Arc::new(AtomicBool::new(true)), game_running: Arc::new(AtomicBool::new(true)),
simulation_tick: Arc::new(AtomicU64::new(0)),
event_sender: broadcast::channel(256).0, event_sender: broadcast::channel(256).0,
event_sequence: Arc::new(Mutex::new(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() let app = Router::new()
.route("/", get(user_ui)) .route("/", get(user_ui))
.route("/admin", get(admin_ui)) .route("/admin", get(admin_ui))
@ -806,7 +1058,7 @@ async fn upload_core(
)); ));
} }
// Sandboxed load validates the module without requiring agent-specific exports. // 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 digest = ring::digest::digest(&ring::digest::SHA256, &body);
let sha256 = digest let sha256 = digest
@ -905,6 +1157,7 @@ fn snapshot_event(state: &AppState) -> StateEvent {
event: "snapshot".to_string(), event: "snapshot".to_string(),
data: serde_json::json!({ data: serde_json::json!({
"running": state.game_running.load(Ordering::SeqCst), "running": state.game_running.load(Ordering::SeqCst),
"tick": state.simulation_tick.load(Ordering::SeqCst),
"sequence": sequence, "sequence": sequence,
}), }),
} }
@ -1055,7 +1308,7 @@ mod tests {
use ring::signature::Ed25519KeyPair; use ring::signature::Ed25519KeyPair;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
fn test_state(directory: &Path) -> (AppState, String, KeyPair) { fn test_state(directory: &Path) -> (AppState, String, KeyPair) {
@ -1072,6 +1325,7 @@ mod tests {
let state = AppState { let state = AppState {
database: Arc::new(Mutex::new(database)), database: Arc::new(Mutex::new(database)),
game_running: Arc::new(AtomicBool::new(true)), game_running: Arc::new(AtomicBool::new(true)),
simulation_tick: Arc::new(AtomicU64::new(0)),
event_sender: tokio::sync::broadcast::channel(256).0, event_sender: tokio::sync::broadcast::channel(256).0,
event_sequence: Arc::new(Mutex::new(0)), event_sequence: Arc::new(Mutex::new(0)),
}; };

View File

@ -4,7 +4,7 @@ use std::fmt;
use extism::{Manifest, Plugin, PluginBuilder, Wasm}; use extism::{Manifest, Plugin, PluginBuilder, Wasm};
use serde::{Deserialize, Serialize}; 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_MODULE_BYTES: usize = 1_048_576;
const MAX_MEMORY_PAGES: u32 = 32; const MAX_MEMORY_PAGES: u32 = 32;