step
This commit is contained in:
commit
591bbc701e
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
name: Wolloseum Systems Engineer
|
||||
description: "Use when designing or implementing Wolloseum: a deterministic procedural game where uploaded WASM modules control spawned agents, with certificate-based authentication and separate admin and player web UIs."
|
||||
tools: [read, edit, search, execute]
|
||||
---
|
||||
You are the systems engineer for Wolloseum. Help design and implement the game server, deterministic simulation, procedural world generation, WASM agent runtime, certificate-based authentication, and the admin and player web UIs. Work in the existing repository and follow its established Rust and project conventions.
|
||||
|
||||
## Core Requirements
|
||||
- Generate all world and gameplay content through procedural generation. Keep generation reproducible from explicit seeds and versioned rules; do not introduce hand-authored level content as a substitute for the generator.
|
||||
- Guarantee replay on a pinned server/runtime build: identical seeds, versions/configuration, and ordered external inputs should reproduce simulation outcomes. Identify every source of nondeterminism, including RNG, iteration order, time, persistence, and WASM host calls. Define stable ordering and represent delayed messages and administrative changes as explicitly ordered simulation inputs, using ticks where appropriate.
|
||||
- Treat uploaded WASM as untrusted code. Use a sandboxed runtime with a narrowly defined host interface, and enforce resource limits such as instruction fuel and memory. Do not expose ambient filesystem, network, clock, or entropy access to agent code.
|
||||
- Make the runtime and simulation contract explicit enough to replay and test: record seeds, relevant versions/configuration, ordered inputs, and outcomes as appropriate. Do not claim cross-platform or cross-runtime determinism without verifying it.
|
||||
- Use certificates as the basis for authentication. Inspect the existing implementation before choosing certificate issuance, trust, identity mapping, renewal, and revocation behavior; do not silently replace certificate authentication with passwords or bearer tokens.
|
||||
- Provide two distinct web interfaces: an admin interface with a console and privileged controls for supported server state, and a user interface showing only that user's WASM agent logs with a text input that accepts arbitrary text, including JSON, and schedules delivery after the configured delay.
|
||||
- Protect privileged administration with explicit authorization and audit records. Give admins control over all typed server-managed state; do not turn the console into arbitrary shell execution or unrestricted filesystem access.
|
||||
|
||||
## Constraints
|
||||
- Preserve the repository's existing stack and public behavior unless the task calls for a change; inspect before selecting new dependencies or architecture.
|
||||
- Keep simulation logic separate from network, UI, wall-clock, and persistence effects so it can be tested and replayed in isolation.
|
||||
- Keep agent messages as text unless the protocol explicitly defines JSON parsing; do not assume every message is JSON.
|
||||
- Do not weaken sandboxing, authentication, authorization, or determinism to make a demo easier.
|
||||
- Avoid broad rewrites. Implement the smallest coherent change, with focused tests for behavior and failure cases.
|
||||
|
||||
## Approach
|
||||
1. Inspect the relevant code, repository instructions, and nearby tests; state the local behavior and the smallest useful validation check before editing.
|
||||
2. Follow the owning abstraction and define the contract at boundaries: simulation ticks and ordered inputs, generator seeds, WASM imports and limits, certificate identity, and UI-to-server actions.
|
||||
3. Implement narrowly, keeping nondeterministic effects outside the simulation core and making external inputs explicit and auditable.
|
||||
4. Run the most focused relevant test or check immediately after editing, then the necessary broader checks. Report anything that remains unverified.
|
||||
|
||||
## Output
|
||||
For implementation tasks, summarize the behavior changed, the key files, and the checks run. State consequential assumptions or unresolved design choices, especially around determinism guarantees, certificate lifecycle, and admin permissions.
|
||||
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "wolloseum"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = "1"
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
pub mod simulation;
|
||||
pub mod wasm_agent;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
use std::collections::BTreeMap;
|
||||
use wolloseum::simulation::{Action, AgentId, World};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut world = World::generate(0x574F4C4C, 32, 32)?;
|
||||
let agent_id = AgentId(1);
|
||||
world.spawn_agent(agent_id)?;
|
||||
world.step(&BTreeMap::from([(agent_id, Action::Wait)]))?;
|
||||
|
||||
let (grass, water, rock) = world.terrain_counts();
|
||||
let agent = world.agent(agent_id).expect("agent was just spawned");
|
||||
println!(
|
||||
"Wolloseum seed={} map={}x{} tick={} terrain=({} grass, {} water, {} rock) agent={} at {:?}",
|
||||
world.seed(),
|
||||
world.dimensions().0,
|
||||
world.dimensions().1,
|
||||
world.tick(),
|
||||
grass,
|
||||
water,
|
||||
rock,
|
||||
agent.id().0,
|
||||
agent.position(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
const MAX_WORLD_CELLS: usize = 1_048_576;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Terrain {
|
||||
Grass,
|
||||
Water,
|
||||
Rock,
|
||||
}
|
||||
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ObservedTerrain {
|
||||
Grass = 0,
|
||||
Water = 1,
|
||||
Rock = 2,
|
||||
Boundary = 3,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct AgentId(pub u64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Direction {
|
||||
North,
|
||||
East,
|
||||
South,
|
||||
West,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Action {
|
||||
Move(Direction),
|
||||
Wait,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct AgentObservation {
|
||||
pub tick: u64,
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub adjacent: [ObservedTerrain; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Agent {
|
||||
id: AgentId,
|
||||
x: u16,
|
||||
y: u16,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn id(&self) -> AgentId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn position(&self) -> (u16, u16) {
|
||||
(self.x, self.y)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SimulationError {
|
||||
InvalidDimensions,
|
||||
WorldTooLarge,
|
||||
AgentAlreadyExists(AgentId),
|
||||
NoSpawnCellAvailable,
|
||||
UnknownAgent(AgentId),
|
||||
TickOverflow,
|
||||
}
|
||||
|
||||
impl fmt::Display for SimulationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidDimensions => write!(formatter, "world dimensions must be nonzero"),
|
||||
Self::WorldTooLarge => write!(formatter, "world exceeds the cell limit"),
|
||||
Self::AgentAlreadyExists(id) => write!(formatter, "agent {} already exists", id.0),
|
||||
Self::NoSpawnCellAvailable => {
|
||||
write!(formatter, "no unoccupied grass cell is available")
|
||||
}
|
||||
Self::UnknownAgent(id) => write!(formatter, "agent {} does not exist", id.0),
|
||||
Self::TickOverflow => write!(formatter, "simulation tick overflow"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SimulationError {}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct World {
|
||||
seed: u64,
|
||||
width: u16,
|
||||
height: u16,
|
||||
tick: u64,
|
||||
tiles: Vec<Terrain>,
|
||||
agents: BTreeMap<AgentId, Agent>,
|
||||
spawn_cursor: usize,
|
||||
}
|
||||
|
||||
impl World {
|
||||
pub fn generate(seed: u64, width: u16, height: u16) -> Result<Self, SimulationError> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(SimulationError::InvalidDimensions);
|
||||
}
|
||||
|
||||
let cell_count = usize::from(width)
|
||||
.checked_mul(usize::from(height))
|
||||
.filter(|count| *count <= MAX_WORLD_CELLS)
|
||||
.ok_or(SimulationError::WorldTooLarge)?;
|
||||
|
||||
let mut random = SplitMix64::new(seed);
|
||||
let spawn_cursor = (random.next() as usize) % cell_count;
|
||||
let tiles = (0..cell_count)
|
||||
.map(|_| match random.next() % 100 {
|
||||
0..=9 => Terrain::Water,
|
||||
10..=19 => Terrain::Rock,
|
||||
_ => Terrain::Grass,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
seed,
|
||||
width,
|
||||
height,
|
||||
tick: 0,
|
||||
tiles,
|
||||
agents: BTreeMap::new(),
|
||||
spawn_cursor,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seed(&self) -> u64 {
|
||||
self.seed
|
||||
}
|
||||
|
||||
pub fn dimensions(&self) -> (u16, u16) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn tick(&self) -> u64 {
|
||||
self.tick
|
||||
}
|
||||
|
||||
pub fn terrain_at(&self, x: u16, y: u16) -> Option<Terrain> {
|
||||
self.index(x, y).map(|index| self.tiles[index])
|
||||
}
|
||||
|
||||
pub fn terrain_counts(&self) -> (usize, usize, usize) {
|
||||
self.tiles.iter().fold((0, 0, 0), |mut counts, terrain| {
|
||||
match terrain {
|
||||
Terrain::Grass => counts.0 += 1,
|
||||
Terrain::Water => counts.1 += 1,
|
||||
Terrain::Rock => counts.2 += 1,
|
||||
}
|
||||
counts
|
||||
})
|
||||
}
|
||||
|
||||
pub fn agents(&self) -> impl Iterator<Item = &Agent> {
|
||||
self.agents.values()
|
||||
}
|
||||
|
||||
pub fn agent(&self, id: AgentId) -> Option<&Agent> {
|
||||
self.agents.get(&id)
|
||||
}
|
||||
|
||||
pub fn observation(&self, id: AgentId) -> Result<AgentObservation, SimulationError> {
|
||||
let agent = self
|
||||
.agents
|
||||
.get(&id)
|
||||
.ok_or(SimulationError::UnknownAgent(id))?;
|
||||
let x = i32::from(agent.x);
|
||||
let y = i32::from(agent.y);
|
||||
|
||||
Ok(AgentObservation {
|
||||
tick: self.tick,
|
||||
x: agent.x,
|
||||
y: agent.y,
|
||||
adjacent: [
|
||||
self.observed_terrain(x, y - 1),
|
||||
self.observed_terrain(x + 1, y),
|
||||
self.observed_terrain(x, y + 1),
|
||||
self.observed_terrain(x - 1, y),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn_agent(&mut self, id: AgentId) -> Result<(), SimulationError> {
|
||||
if self.agents.contains_key(&id) {
|
||||
return Err(SimulationError::AgentAlreadyExists(id));
|
||||
}
|
||||
|
||||
let cell_count = self.tiles.len();
|
||||
let spawn_index = (0..cell_count)
|
||||
.map(|offset| (self.spawn_cursor + offset) % cell_count)
|
||||
.find(|index| {
|
||||
self.tiles[*index] == Terrain::Grass
|
||||
&& !self.agents.values().any(|agent| {
|
||||
usize::from(agent.y) * usize::from(self.width) + usize::from(agent.x)
|
||||
== *index
|
||||
})
|
||||
})
|
||||
.ok_or(SimulationError::NoSpawnCellAvailable)?;
|
||||
let x = (spawn_index % usize::from(self.width)) as u16;
|
||||
let y = (spawn_index / usize::from(self.width)) as u16;
|
||||
self.agents.insert(id, Agent { id, x, y });
|
||||
self.spawn_cursor = (spawn_index + 1) % cell_count;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn step(&mut self, actions: &BTreeMap<AgentId, Action>) -> Result<(), SimulationError> {
|
||||
let next_tick = self
|
||||
.tick
|
||||
.checked_add(1)
|
||||
.ok_or(SimulationError::TickOverflow)?;
|
||||
if let Some(id) = actions.keys().find(|id| !self.agents.contains_key(id)) {
|
||||
return Err(SimulationError::UnknownAgent(*id));
|
||||
}
|
||||
|
||||
for (id, action) in actions {
|
||||
if matches!(action, Action::Wait) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let agent = self.agents.get(id).expect("actions were validated");
|
||||
let (dx, dy) = match action {
|
||||
Action::Move(Direction::North) => (0, -1),
|
||||
Action::Move(Direction::East) => (1, 0),
|
||||
Action::Move(Direction::South) => (0, 1),
|
||||
Action::Move(Direction::West) => (-1, 0),
|
||||
Action::Wait => unreachable!(),
|
||||
};
|
||||
let target_x = i32::from(agent.x) + dx;
|
||||
let target_y = i32::from(agent.y) + dy;
|
||||
if target_x < 0
|
||||
|| target_y < 0
|
||||
|| target_x >= i32::from(self.width)
|
||||
|| target_y >= i32::from(self.height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_x = target_x as u16;
|
||||
let target_y = target_y as u16;
|
||||
if self.terrain_at(target_x, target_y) != Some(Terrain::Grass) {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.agents
|
||||
.values()
|
||||
.any(|other| other.id != *id && other.position() == (target_x, target_y))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let agent = self.agents.get_mut(id).expect("actions were validated");
|
||||
agent.x = target_x;
|
||||
agent.y = target_y;
|
||||
}
|
||||
|
||||
self.tick = next_tick;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn index(&self, x: u16, y: u16) -> Option<usize> {
|
||||
if x >= self.width || y >= self.height {
|
||||
return None;
|
||||
}
|
||||
Some(usize::from(y) * usize::from(self.width) + usize::from(x))
|
||||
}
|
||||
|
||||
fn observed_terrain(&self, x: i32, y: i32) -> ObservedTerrain {
|
||||
if x < 0 || y < 0 || x >= i32::from(self.width) || y >= i32::from(self.height) {
|
||||
return ObservedTerrain::Boundary;
|
||||
}
|
||||
|
||||
match self.terrain_at(x as u16, y as u16) {
|
||||
Some(Terrain::Grass) => ObservedTerrain::Grass,
|
||||
Some(Terrain::Water) => ObservedTerrain::Water,
|
||||
Some(Terrain::Rock) => ObservedTerrain::Rock,
|
||||
None => unreachable!("coordinates were checked against world dimensions"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SplitMix64 {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl SplitMix64 {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
|
||||
let mut value = self.state;
|
||||
value = (value ^ (value >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||
value = (value ^ (value >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||
value ^ (value >> 31)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Action, AgentId, Direction, SimulationError, World};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn same_seed_and_ordered_actions_replay_identically() {
|
||||
let mut first = World::generate(0x574F4C4C, 32, 32).unwrap();
|
||||
let mut second = World::generate(0x574F4C4C, 32, 32).unwrap();
|
||||
let first_agent = AgentId(1);
|
||||
let second_agent = AgentId(2);
|
||||
first.spawn_agent(first_agent).unwrap();
|
||||
first.spawn_agent(second_agent).unwrap();
|
||||
second.spawn_agent(first_agent).unwrap();
|
||||
second.spawn_agent(second_agent).unwrap();
|
||||
|
||||
for tick in 0..20 {
|
||||
let actions = BTreeMap::from([
|
||||
(
|
||||
first_agent,
|
||||
if tick % 2 == 0 {
|
||||
Action::Move(Direction::East)
|
||||
} else {
|
||||
Action::Wait
|
||||
},
|
||||
),
|
||||
(second_agent, Action::Move(Direction::South)),
|
||||
]);
|
||||
first.step(&actions).unwrap();
|
||||
second.step(&actions).unwrap();
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_action_does_not_partially_advance_world() {
|
||||
let mut world = World::generate(42, 8, 8).unwrap();
|
||||
let before = world.clone();
|
||||
let actions = BTreeMap::from([(AgentId(404), Action::Wait)]);
|
||||
|
||||
assert_eq!(
|
||||
world.step(&actions),
|
||||
Err(SimulationError::UnknownAgent(AgentId(404)))
|
||||
);
|
||||
assert_eq!(world, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_must_be_nonzero() {
|
||||
assert_eq!(
|
||||
World::generate(1, 0, 8).unwrap_err(),
|
||||
SimulationError::InvalidDimensions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
use extism::{Manifest, Plugin, PluginBuilder, Wasm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::simulation::{Action, AgentObservation, Direction, ObservedTerrain};
|
||||
|
||||
const MAX_MODULE_BYTES: usize = 1_048_576;
|
||||
const MAX_MEMORY_PAGES: u32 = 16;
|
||||
pub const FUEL_PER_DECISION: u64 = 100_000;
|
||||
pub const AGENT_ABI_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum WasmAgentError {
|
||||
ModuleTooLarge,
|
||||
PluginLoadFailed(String),
|
||||
OutOfFuel,
|
||||
ExecutionFailed(String),
|
||||
InvalidActionCode(i32),
|
||||
InvalidDecision(String),
|
||||
UnsupportedAbiVersion(u16),
|
||||
TickOutOfRange,
|
||||
}
|
||||
|
||||
impl fmt::Display for WasmAgentError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ModuleTooLarge => write!(formatter, "WASM module exceeds the byte limit"),
|
||||
Self::PluginLoadFailed(error) => {
|
||||
write!(formatter, "WASM plugin could not load: {error}")
|
||||
}
|
||||
Self::OutOfFuel => write!(formatter, "WASM decision exceeded its fuel limit"),
|
||||
Self::ExecutionFailed(error) => write!(formatter, "WASM decision failed: {error}"),
|
||||
Self::InvalidActionCode(code) => write!(formatter, "invalid WASM action code {code}"),
|
||||
Self::InvalidDecision(error) => write!(formatter, "invalid WASM decision: {error}"),
|
||||
Self::UnsupportedAbiVersion(version) => {
|
||||
write!(formatter, "unsupported WASM agent ABI version {version}")
|
||||
}
|
||||
Self::TickOutOfRange => write!(formatter, "simulation tick exceeds the WASM ABI range"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for WasmAgentError {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DecisionInput {
|
||||
abi_version: u16,
|
||||
tick: u64,
|
||||
x: u16,
|
||||
y: u16,
|
||||
adjacent_terrain: [i32; 4],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecisionOutput {
|
||||
abi_version: u16,
|
||||
action: i32,
|
||||
}
|
||||
|
||||
pub struct WasmAgent {
|
||||
plugin: Plugin,
|
||||
}
|
||||
|
||||
impl WasmAgent {
|
||||
pub fn load(bytes: &[u8]) -> Result<Self, WasmAgentError> {
|
||||
if bytes.len() > MAX_MODULE_BYTES {
|
||||
return Err(WasmAgentError::ModuleTooLarge);
|
||||
}
|
||||
|
||||
let manifest =
|
||||
Manifest::new([Wasm::data(bytes.to_vec())]).with_memory_max(MAX_MEMORY_PAGES);
|
||||
let plugin = PluginBuilder::new(manifest)
|
||||
.with_wasi(false)
|
||||
.with_fuel_limit(FUEL_PER_DECISION)
|
||||
.build()
|
||||
.map_err(|error| WasmAgentError::PluginLoadFailed(error.to_string()))?;
|
||||
|
||||
Ok(Self { plugin })
|
||||
}
|
||||
|
||||
pub fn decide(&mut self, observation: AgentObservation) -> Result<Action, WasmAgentError> {
|
||||
let tick = i64::try_from(observation.tick).map_err(|_| WasmAgentError::TickOutOfRange)?;
|
||||
let input = DecisionInput {
|
||||
abi_version: AGENT_ABI_VERSION,
|
||||
tick: tick as u64,
|
||||
x: observation.x,
|
||||
y: observation.y,
|
||||
adjacent_terrain: observation.adjacent.map(terrain_code),
|
||||
};
|
||||
let input = serde_json::to_vec(&input)
|
||||
.map_err(|error| WasmAgentError::ExecutionFailed(error.to_string()))?;
|
||||
let output: &[u8] = self
|
||||
.plugin
|
||||
.call("decide", &input)
|
||||
.map_err(map_execution_error)?;
|
||||
let output: DecisionOutput = serde_json::from_slice(output)
|
||||
.map_err(|error| WasmAgentError::InvalidDecision(error.to_string()))?;
|
||||
if output.abi_version != AGENT_ABI_VERSION {
|
||||
return Err(WasmAgentError::UnsupportedAbiVersion(output.abi_version));
|
||||
}
|
||||
|
||||
match output.action {
|
||||
0 => Ok(Action::Wait),
|
||||
1 => Ok(Action::Move(Direction::North)),
|
||||
2 => Ok(Action::Move(Direction::East)),
|
||||
3 => Ok(Action::Move(Direction::South)),
|
||||
4 => Ok(Action::Move(Direction::West)),
|
||||
code => Err(WasmAgentError::InvalidActionCode(code)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terrain_code(terrain: ObservedTerrain) -> i32 {
|
||||
terrain as i32
|
||||
}
|
||||
|
||||
fn map_execution_error(error: impl fmt::Display) -> WasmAgentError {
|
||||
let message = error.to_string();
|
||||
if message.to_ascii_lowercase().contains("fuel") {
|
||||
WasmAgentError::OutOfFuel
|
||||
} else {
|
||||
WasmAgentError::ExecutionFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{WasmAgent, WasmAgentError};
|
||||
use crate::simulation::{Action, AgentId, AgentObservation, Direction, ObservedTerrain, World};
|
||||
|
||||
fn output_module(abi_version: u16, action: i32, check_memory_limit: bool) -> Vec<u8> {
|
||||
let output = format!(r#"{{"abi_version":{abi_version},"action":{action}}}"#);
|
||||
let stores = output
|
||||
.as_bytes()
|
||||
.chunks(8)
|
||||
.enumerate()
|
||||
.map(|(index, chunk)| {
|
||||
let mut bytes = [0; 8];
|
||||
bytes[..chunk.len()].copy_from_slice(chunk);
|
||||
format!(
|
||||
"(call $store_u64 (i64.add (local.get $output) (i64.const {})) (i64.const {}))",
|
||||
index * 8,
|
||||
i64::from_le_bytes(bytes)
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let memory_check = if check_memory_limit {
|
||||
"(local.set $previous (memory.grow (i32.const 16))) (local.get $previous) (i32.const -1) (i32.ne) (if (then (unreachable)))"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let memory_declaration = if check_memory_limit {
|
||||
"(memory (export \"memory\") 1)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let previous_local = if check_memory_limit {
|
||||
"(local $previous i32)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let source = format!(
|
||||
r#"(module
|
||||
(import "extism:host/env" "alloc" (func $alloc (param i64) (result i64)))
|
||||
(import "extism:host/env" "store_u64" (func $store_u64 (param i64 i64)))
|
||||
(import "extism:host/env" "output_set" (func $output_set (param i64 i64)))
|
||||
{memory_declaration}
|
||||
(func (export "decide") (result i32)
|
||||
{previous_local}
|
||||
(local $output i64)
|
||||
{memory_check}
|
||||
(local.set $output (call $alloc (i64.const {})))
|
||||
{stores}
|
||||
(call $output_set (local.get $output) (i64.const {}))
|
||||
(i32.const 0)
|
||||
)
|
||||
)"#,
|
||||
output.len(),
|
||||
output.len()
|
||||
);
|
||||
wat::parse_str(source).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extism_plugin_returns_stable_action_codes() {
|
||||
let wasm = output_module(1, 1, false);
|
||||
let mut world = World::generate(0x574F4C4C, 32, 32).unwrap();
|
||||
let id = AgentId(1);
|
||||
world.spawn_agent(id).unwrap();
|
||||
let observation = world.observation(id).unwrap();
|
||||
let mut agent = WasmAgent::load(&wasm).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
agent.decide(observation),
|
||||
Ok(Action::Move(Direction::North))
|
||||
);
|
||||
assert_eq!(
|
||||
agent.decide(observation),
|
||||
Ok(Action::Move(Direction::North))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasi_imports_are_rejected() {
|
||||
let wasm = wat::parse_str(
|
||||
r#"(module
|
||||
(import "wasi_snapshot_preview1" "random_get"
|
||||
(func $random_get (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "decide") (result i32) (i32.const 0))
|
||||
)"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(WasmAgent::load(&wasm).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_growth_is_limited() {
|
||||
let wasm = output_module(1, 0, true);
|
||||
let mut agent = WasmAgent::load(&wasm).unwrap();
|
||||
let observation = AgentObservation {
|
||||
tick: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
adjacent: [ObservedTerrain::Grass; 4],
|
||||
};
|
||||
|
||||
assert!(agent.decide(observation).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinite_loop_traps_at_fuel_limit() {
|
||||
let wasm = wat::parse_str(
|
||||
r#"(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "decide") (result i32)
|
||||
(loop $forever (br $forever))
|
||||
(i32.const 0)
|
||||
)
|
||||
)"#,
|
||||
)
|
||||
.unwrap();
|
||||
let observation = AgentObservation {
|
||||
tick: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
adjacent: [ObservedTerrain::Boundary; 4],
|
||||
};
|
||||
let mut agent = WasmAgent::load(&wasm).unwrap();
|
||||
|
||||
assert_eq!(agent.decide(observation), Err(WasmAgentError::OutOfFuel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_action_code_is_rejected() {
|
||||
let wasm = output_module(1, 99, false);
|
||||
let observation = AgentObservation {
|
||||
tick: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
adjacent: [ObservedTerrain::Grass; 4],
|
||||
};
|
||||
let mut agent = WasmAgent::load(&wasm).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
agent.decide(observation),
|
||||
Err(WasmAgentError::InvalidActionCode(99))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_abi_version_is_rejected() {
|
||||
let wasm = output_module(2, 0, false);
|
||||
let mut agent = WasmAgent::load(&wasm).unwrap();
|
||||
let observation = AgentObservation {
|
||||
tick: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
adjacent: [ObservedTerrain::Grass; 4],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
agent.decide(observation),
|
||||
Err(WasmAgentError::UnsupportedAbiVersion(2))
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue