diff --git a/Cargo.lock b/Cargo.lock index 8b89b70..219b715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,13 @@ dependencies = [ "gimli", ] +[[package]] +name = "agent-api" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -453,6 +460,13 @@ dependencies = [ "memchr", ] +[[package]] +name = "core-api" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -698,6 +712,8 @@ dependencies = [ name = "default-core" version = "0.1.0" dependencies = [ + "agent-api", + "core-api", "extism-pdk", "serde", "serde_json", @@ -4006,9 +4022,11 @@ dependencies = [ name = "wolloseum" version = "0.1.0" dependencies = [ + "agent-api", "axum", "base64 0.22.1", "clap", + "core-api", "default-core", "extism", "rcgen", diff --git a/Cargo.toml b/Cargo.toml index 94a410b..4ec43eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,14 +4,16 @@ version = "0.1.0" edition = "2024" [workspace] -members = ["default-core"] +members = ["agent-api", "core-api", "default-core"] exclude = ["example-wasm-agent"] resolver = "3" [dependencies] axum = { version = "0.8", features = ["ws"] } +agent-api = { path = "agent-api" } base64 = "0.22" clap = { version = "4.6.7", features = ["derive", "env"] } +core-api = { path = "core-api" } default-core = { path = "default-core" } extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] } rcgen = "0.14" diff --git a/Dockerfile b/Dockerfile index 857ea89..4e70b11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM rust:1-bookworm AS builder WORKDIR /build COPY Cargo.toml Cargo.lock ./ +COPY agent-api ./agent-api +COPY core-api ./core-api COPY default-core ./default-core COPY src ./src COPY web ./web diff --git a/README.md b/README.md index 957a0c2..41f5a10 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,13 @@ Set `WOLLOSEUM_UID` and `WOLLOSEUM_GID` in the environment or a Compose `.env` file to choose the container process identity. Both default to `1000`. The ownership of the host `./data` tree is changed to these IDs at startup. -The first startup logs the administrator identity ID and the key path inside -the container. Copy the key to a secure host location for admin sign-in: +The first startup logs the administrator identity ID and identity bundle path +inside the container. Copy the bundle to a secure host location for admin +sign-in: ```sh -docker compose cp wolloseum:/var/lib/wolloseum/keys/admin-private.pem /secure/path/admin-private.pem -chmod 600 /secure/path/admin-private.pem +docker compose cp wolloseum:/var/lib/wolloseum/keys/admin-identity.json /secure/path/admin-identity.json +chmod 600 /secure/path/admin-identity.json ``` Back up the key and database volume. To stop the server while preserving data, @@ -51,6 +52,7 @@ well, run `docker compose down --volumes`. The server bootstraps an administrator keypair on first start: - `data/keys/admin-private.pem` is the owner-only Ed25519 PKCS#8 private key. +- `data/keys/admin-identity.json` is the owner-only bundle used by browser sign-in. - The server prints the corresponding identity ID at startup. - Keep a secure backup of the private key. The server cannot recover it. @@ -68,27 +70,28 @@ Configuration flags and environment variables: ## Web Interfaces -- `/` is the player console. Users sign in with their identity key and upload or replace their agent WASM. -- `/admin` is the administrator console. It creates and removes users, pauses or resumes the simulation, and replaces the core WASM module. +- `/` is the player console. Every identity, including moderators and administrators, can act as a player and upload or replace its own agent WASM. +- `/admin` is the privileged console. Moderators manage player accounts; administrators can also manage roles, simulation settings and world state, and replace the core WASM module. - `/viewer` is a public live event viewer. It connects to `/api/events`, tracks event sequence numbers, and restores state from snapshots after reconnecting. -The admin console generates a fresh Ed25519 keypair when creating a player. +The admin console generates a fresh Ed25519 keypair when creating a user. The server receives only the public key. The browser downloads a JSON identity bundle containing the new identity ID and private key; deliver that bundle to -the player through a secure channel. The private key is not stored by the -server. +the user through a secure channel. The private key is not stored by the +server. Administrators can promote users to moderator or administrator roles. -On the admin page, enter the startup identity ID and select -`data/keys/admin-private.pem`. For an existing installation, select the legacy -admin key and use the identity ID printed by the server. +Sign in on either page by selecting the JSON identity bundle. Protect +`data/keys/admin-identity.json` like the private key; the older PEM key remains +available for CLI compatibility. ## Browser Authentication -1. The browser requests `POST /api/auth/challenge` with `{ "client_id": "..." }`. -2. The server returns a random, short-lived challenge. -3. The browser signs the decoded challenge bytes using Ed25519 and posts the identity ID, challenge, and base64 signature to `POST /api/auth/login`. -4. The server verifies the signature against the stored public key and returns an opaque session token. -5. The browser stores the token and sends it as `Authorization: Bearer ` on protected API requests. +1. The browser reads the identity ID and private key from the selected JSON bundle. +2. The browser requests `POST /api/auth/challenge` with `{ "client_id": "..." }`. +3. The server returns a random, short-lived challenge. +4. The browser signs the decoded challenge bytes using Ed25519 and posts the identity ID, challenge, and base64 signature to `POST /api/auth/login`. +5. The server verifies the signature against the stored public key and returns an opaque session token. +6. The browser stores the token and sends it as `Authorization: Bearer ` on protected API requests. Challenges expire after five minutes and are single-use. Session tokens expire seven days after the last authenticated API interaction. Each authenticated @@ -97,22 +100,26 @@ Revoking an identity by deleting it also removes its sessions. ## API -| Method | Path | Access | Purpose | -| ------ | --------------------- | ---------------- | ------------------------------------------------- | -| `POST` | `/api/auth/challenge` | Public | Request an identity challenge | -| `POST` | `/api/auth/login` | Public | Verify a signed challenge and issue a token | -| `GET` | `/api/me` | Session | Read the signed-in identity and agent status | -| `GET` | `/api/users` | Admin session | List identities and agent digests | -| `GET` | `/api/status` | Public | Read the simulation running state | -| `GET` | `/api/settings` | Admin session | Read simulation calendar and agent activation settings | -| `PUT` | `/api/settings` | Admin session | Update simulation calendar and agent activation settings | -| `GET` | `/api/events` | Public WebSocket | Receive snapshots and ordered state events | -| `POST` | `/api/create_user` | Admin session | Register a username and base64 Ed25519 public key | -| `POST` | `/api/delete_user` | Admin session | Delete a player and its uploaded agent | -| `POST` | `/api/start_game` | Admin session | Resume the simulation | -| `POST` | `/api/stop_game` | Admin session | Pause the simulation | -| `POST` | `/api/update_agent` | User session | Queue or replace the caller's next agent WASM | -| `POST` | `/api/upload_core` | Admin session | Upload or replace the core simulation WASM | +| Method | Path | Access | Purpose | +| ------ | --------------------- | ---------------- | --------------------------------------------------------- | +| `POST` | `/api/auth/challenge` | Public | Request an identity challenge | +| `POST` | `/api/auth/login` | Public | Verify a signed challenge and issue a token | +| `GET` | `/api/me` | Session | Read the signed-in identity and agent status | +| `GET` | `/api/users` | Mod or admin | List identities and agent digests | +| `GET` | `/api/status` | Public | Read the simulation running state | +| `GET` | `/api/settings` | Admin session | Read simulation calendar, activation, and tick interval | +| `PUT` | `/api/settings` | Admin session | Update simulation calendar, activation, and tick interval | +| `GET` | `/api/state` | Admin session | Download the current state bundle | +| `POST` | `/api/state` | Admin session | Validate and restore a state bundle | +| `POST` | `/api/reset_world` | Admin session | Regenerate the world while preserving other records | +| `GET` | `/api/events` | Public WebSocket | Receive snapshots and ordered state events | +| `POST` | `/api/create_user` | Mod or admin | Register a username and base64 Ed25519 public key | +| `POST` | `/api/delete_user` | Mod or admin | Delete a player and its uploaded agent | +| `POST` | `/api/set_role` | Admin session | Assign player, moderator, or administrator role | +| `POST` | `/api/start_game` | Admin session | Resume the simulation | +| `POST` | `/api/stop_game` | Admin session | Pause the simulation | +| `POST` | `/api/update_agent` | User session | Queue or replace the caller's next agent WASM | +| `POST` | `/api/upload_core` | Admin session | Upload or replace the core simulation WASM | Agent and core WASM uploads are limited to 1 MiB and validated in the sandboxed runtime before they are persisted. Existing signed request headers @@ -121,8 +128,24 @@ remain accepted for compatibility with older clients. Agent uploads replace that player's queued module; the active module remains in use until the selected update boundary. The admin console configures ticks per day, days per week and month, and whether queued agents activate between -ticks, days, weeks, or months. Defaults are 100 ticks per day, 7 days per week, -30 days per month, and activation between ticks. +ticks, days, weeks, or months. Defaults are 86400 ticks per day, 7 days per week, +30 days per month, activation between ticks, and a 100 ms delay between ticks. +The delay can be set from 1 to 60000 milliseconds. + +State downloads contain the current world, simulation settings, run state, +user public identities, active and queued agent modules, and core module. They +do not contain private keys or login sessions. Restoring a bundle requires it +to retain the active administrator performing the restore. Resetting creates a +fresh random world while preserving settings, users, agents, and the active +core. + +## Rust APIs + +The `agent-api` crate defines typed observations, actions, and the `Agent` trait. +The `core-api` crate defines serialized tick input/output and the `Core` trait. +`default-core/` implements `Core`; `example-wasm-agent/` implements `Agent` and +adapts it to the Extism ABI. Both API crates are workspace members and can be +used as dependencies when authoring new implementations. 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` diff --git a/agent-api/Cargo.toml b/agent-api/Cargo.toml new file mode 100644 index 0000000..141cb48 --- /dev/null +++ b/agent-api/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "agent-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } \ No newline at end of file diff --git a/agent-api/src/lib.rs b/agent-api/src/lib.rs new file mode 100644 index 0000000..4c6a551 --- /dev/null +++ b/agent-api/src/lib.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ObservedTerrain { + Grass = 0, + Water = 1, + Rock = 2, + Boundary = 3, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum Direction { + North, + East, + South, + West, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum Action { + Move(Direction), + Wait, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct AgentObservation { + pub tick: u64, + pub x: u16, + pub y: u16, + pub adjacent: [ObservedTerrain; 4], +} + +pub trait Agent { + type Error; + + fn decide(&mut self, observation: AgentObservation) -> Result; +} diff --git a/core-api/Cargo.toml b/core-api/Cargo.toml new file mode 100644 index 0000000..0fc1809 --- /dev/null +++ b/core-api/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "core-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } \ No newline at end of file diff --git a/core-api/src/lib.rs b/core-api/src/lib.rs new file mode 100644 index 0000000..6e3bc0b --- /dev/null +++ b/core-api/src/lib.rs @@ -0,0 +1,26 @@ +use std::error::Error; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CoreTickInput { + pub world: W, + pub actions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CoreTickOutput { + pub world: W, +} + +pub trait Core { + type World: Serialize + DeserializeOwned; + type Action: Serialize + DeserializeOwned; + type Error: Error + Send + Sync + 'static; + + fn tick( + &mut self, + input: CoreTickInput, + ) -> Result, Self::Error>; +} diff --git a/default-core/Cargo.toml b/default-core/Cargo.toml index 8a4d1dc..dec3f3c 100644 --- a/default-core/Cargo.toml +++ b/default-core/Cargo.toml @@ -7,6 +7,8 @@ edition = "2024" crate-type = ["cdylib", "rlib"] [dependencies] +agent-api = { path = "../agent-api" } +core-api = { path = "../core-api" } serde = { version = "1", features = ["derive"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/default-core/src/lib.rs b/default-core/src/lib.rs index 82fbc48..efa5922 100644 --- a/default-core/src/lib.rs +++ b/default-core/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::error::Error; use std::fmt; +pub use agent_api::{Action, AgentObservation, Direction, ObservedTerrain}; use serde::{Deserialize, Serialize}; const MAX_WORLD_CELLS: usize = 1_048_576; @@ -13,40 +14,9 @@ pub enum Terrain { Rock, } -#[repr(i32)] -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum ObservedTerrain { - Grass = 0, - Water = 1, - Rock = 2, - Boundary = 3, -} - #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] pub struct AgentId(pub u64); -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum Direction { - North, - East, - South, - West, -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum Action { - Move(Direction), - Wait, -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct AgentObservation { - pub tick: u64, - pub x: u16, - pub y: u16, - pub adjacent: [ObservedTerrain; 4], -} - #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct AgentAction { pub id: AgentId, @@ -320,16 +290,8 @@ 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, -} +pub type CoreTickInput = core_api::CoreTickInput; +pub type CoreTickOutput = core_api::CoreTickOutput; #[derive(Clone, Debug, Eq, PartialEq)] pub enum CoreError { @@ -366,6 +328,22 @@ pub fn run_tick(mut input: CoreTickInput) -> Result { Ok(CoreTickOutput { world: input.world }) } +#[derive(Default)] +pub struct DefaultCore; + +impl core_api::Core for DefaultCore { + type World = World; + type Action = AgentAction; + type Error = CoreError; + + fn tick( + &mut self, + input: core_api::CoreTickInput, + ) -> Result, Self::Error> { + run_tick(input) + } +} + struct SplitMix64 { state: u64, } diff --git a/example-wasm-agent/Cargo.lock b/example-wasm-agent/Cargo.lock index 783bb87..19d672d 100644 --- a/example-wasm-agent/Cargo.lock +++ b/example-wasm-agent/Cargo.lock @@ -2,6 +2,13 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "agent-api" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -48,6 +55,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" name = "example-wasm-agent" version = "0.1.0" dependencies = [ + "agent-api", "extism-pdk", "serde", ] diff --git a/example-wasm-agent/Cargo.toml b/example-wasm-agent/Cargo.toml index b004281..01204fe 100644 --- a/example-wasm-agent/Cargo.toml +++ b/example-wasm-agent/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] +agent-api = { path = "../agent-api" } extism-pdk = "1" serde = { version = "1", features = ["derive"] } diff --git a/example-wasm-agent/src/lib.rs b/example-wasm-agent/src/lib.rs index ea42286..a3a52dc 100644 --- a/example-wasm-agent/src/lib.rs +++ b/example-wasm-agent/src/lib.rs @@ -1,8 +1,10 @@ +use std::convert::Infallible; + +use agent_api::{Action, Agent, AgentObservation, Direction, ObservedTerrain}; use extism_pdk::*; use serde::{Deserialize, Serialize}; const ABI_VERSION: u16 = 1; -const BOUNDARY_TERRAIN: i32 = 3; #[derive(Deserialize)] struct DecisionInput { @@ -20,39 +22,63 @@ struct DecisionOutput { action: i32, } +struct ExampleAgent; + +impl Agent for ExampleAgent { + type Error = Infallible; + + fn decide(&mut self, observation: AgentObservation) -> Result { + let open_directions: Vec = observation + .adjacent + .iter() + .enumerate() + .filter(|(_, terrain)| **terrain != ObservedTerrain::Boundary) + .map(|(index, _)| match index { + 0 => Direction::North, + 1 => Direction::East, + 2 => Direction::South, + _ => Direction::West, + }) + .collect(); + if open_directions.is_empty() { + return Ok(Action::Wait); + } + let seed = deterministic_seed(observation.tick, observation.x, observation.y); + Ok(Action::Move( + open_directions[(seed as usize) % open_directions.len()], + )) + } +} + /// Deterministic decision function called once per simulation tick. -/// -/// The host provides the full observation as JSON input and expects a JSON -/// decision back; no filesystem, network, clock, or RNG access is available. -/// Movement here is a reproducible function of (tick, x, y) alone, so replay -/// with the same seed and inputs always yields the same actions. +/// The private keyless agent API is adapted to the stable Extism JSON ABI here. #[plugin_fn] pub fn decide(input: Json) -> FnResult> { let input = input.0; - - // Action codes: 1=North, 2=East, 3=South, 4=West, matching adjacent_terrain order. - let open_directions: Vec = input - .adjacent_terrain - .iter() - .enumerate() - .filter(|(_, terrain)| **terrain != BOUNDARY_TERRAIN) - .map(|(index, _)| (index + 1) as i32) - .collect(); - - let action = if open_directions.is_empty() { - 0 // Wait - } else { - let seed = deterministic_seed(input.tick, input.x, input.y); - open_directions[(seed as usize) % open_directions.len()] + let observation = AgentObservation { + tick: input.tick, + x: input.x, + y: input.y, + adjacent: input.adjacent_terrain.map(|terrain| match terrain { + 0 => ObservedTerrain::Grass, + 1 => ObservedTerrain::Water, + 2 => ObservedTerrain::Rock, + _ => ObservedTerrain::Boundary, + }), + }; + let action = match ExampleAgent.decide(observation).unwrap() { + Action::Wait => 0, + Action::Move(Direction::North) => 1, + Action::Move(Direction::East) => 2, + Action::Move(Direction::South) => 3, + Action::Move(Direction::West) => 4, }; - Ok(Json(DecisionOutput { abi_version: ABI_VERSION, action, })) } -/// SplitMix64-style mix so the same (tick, x, y) always yields the same choice. fn deterministic_seed(tick: u64, x: u16, y: u16) -> u64 { let mut state = tick .wrapping_mul(0x9E37_79B9_7F4A_7C15) diff --git a/src/core_runtime.rs b/src/core_runtime.rs index 91a2790..b0575c7 100644 --- a/src/core_runtime.rs +++ b/src/core_runtime.rs @@ -1,4 +1,4 @@ -use default_core::{CoreTickInput, CoreTickOutput, World, run_tick}; +use default_core::{CoreTickInput, CoreTickOutput, DefaultCore, World}; use extism::{Manifest, Plugin, PluginBuilder, Wasm}; const CORE_FUEL_LIMIT: u64 = 5_000_000; @@ -35,7 +35,9 @@ impl CoreRuntime { 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::Native => { + core_api::Core::tick(&mut DefaultCore, 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 diff --git a/src/lib.rs b/src/lib.rs index 1af9d55..edc7557 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub mod cli; mod core_runtime; pub mod server; +pub use agent_api; +pub use core_api; pub use default_core as simulation; pub mod wasm_agent; diff --git a/src/server.rs b/src/server.rs index 90d09aa..58dd306 100644 --- a/src/server.rs +++ b/src/server.rs @@ -32,6 +32,7 @@ use crate::core_runtime::CoreRuntime; use crate::wasm_agent::WasmAgent; const MAX_WASM_BYTES: usize = 1_048_576; +const MAX_STATE_BYTES: usize = 67_108_864; const MAX_REQUEST_SKEW_SECONDS: u64 = 60; const REQUEST_NONCE_MAX_BYTES: usize = 128; const SESSION_LIFETIME_SECONDS: u64 = 7 * 24 * 60 * 60; @@ -146,7 +147,7 @@ async fn current_identity( let database = state.database.lock().map_err(ApiError::internal)?; let identity = database .query_row( - "SELECT identities.username, identities.is_admin, + "SELECT identities.username, identities.is_admin, identities.is_mod, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM queued_agents WHERE queued_agents.identity_id = identities.id) @@ -157,8 +158,9 @@ async fn current_identity( row.get::<_, String>(0)?, row.get::<_, bool>(1)?, row.get::<_, bool>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, + row.get::<_, bool>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, )) }, ) @@ -167,9 +169,10 @@ async fn current_identity( client_id, username: identity.0, is_admin: identity.1, - has_agent: identity.2, - agent_sha256: identity.3, - queued_agent_sha256: identity.4, + is_mod: identity.2, + has_agent: identity.3, + agent_sha256: identity.4, + queued_agent_sha256: identity.5, })) } @@ -177,11 +180,10 @@ async fn list_users( State(state): State, headers: HeaderMap, ) -> Result>, ApiError> { - let token = bearer_token(&headers)?; - authenticate_session(&state, token, true)?; + authenticate_moderator(&state, &headers, &Method::GET, "/api/users", b"")?; let database = state.database.lock().map_err(ApiError::internal)?; let mut statement = database - .prepare("SELECT identities.id, identities.username, identities.is_admin, + .prepare("SELECT identities.id, identities.username, identities.is_admin, identities.is_mod, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM queued_agents WHERE queued_agents.identity_id = identities.id) @@ -193,9 +195,10 @@ async fn list_users( client_id: row.get(0)?, username: row.get(1)?, is_admin: row.get(2)?, - has_agent: row.get(3)?, - agent_sha256: row.get(4)?, - queued_agent_sha256: row.get(5)?, + is_mod: row.get(3)?, + has_agent: row.get(4)?, + agent_sha256: row.get(5)?, + queued_agent_sha256: row.get(6)?, }) }) .map_err(ApiError::internal)? @@ -217,10 +220,19 @@ struct AppState { database: Arc>, game_running: Arc, simulation_tick: Arc, + tick_interval_ms: Arc, + world_snapshot: Arc>>, + world_command: Arc>>, event_sender: broadcast::Sender, event_sequence: Arc>, } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct SimulationWorldSnapshot { + world: World, + agent_ids: BTreeMap, +} + struct StoredAgent { client_id: String, wasm: Vec, @@ -263,19 +275,22 @@ impl AgentActivationMode { } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(default)] struct SimulationSettings { ticks_per_day: u32, days_per_week: u32, days_per_month: u32, + tick_interval_ms: u32, agent_activation: AgentActivationMode, } impl Default for SimulationSettings { fn default() -> Self { Self { - ticks_per_day: 100, + ticks_per_day: 86400, days_per_week: 7, days_per_month: 30, + tick_interval_ms: 100, agent_activation: AgentActivationMode::BetweenTicks, } } @@ -292,6 +307,9 @@ impl SimulationSettings { if !(1..=366).contains(&self.days_per_month) { return Err("days_per_month must be between 1 and 366"); } + if !(1..=60_000).contains(&self.tick_interval_ms) { + return Err("tick_interval_ms must be between 1 and 60000"); + } Ok(self) } } @@ -322,6 +340,7 @@ struct SimulationLoop { world: World, agents: BTreeMap, next_agent_id: u64, + preferred_agent_ids: BTreeMap, core: Option, core_sha256: Option, } @@ -334,6 +353,7 @@ impl SimulationLoop { .expect("the default world dimensions are valid"), agents: BTreeMap::new(), next_agent_id: 1, + preferred_agent_ids: BTreeMap::new(), core: Some(CoreRuntime::Native), core_sha256: None, } @@ -401,12 +421,18 @@ impl SimulationLoop { 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; + let preferred_id = self.preferred_agent_ids.remove(&stored.client_id); + let id = preferred_id + .map(AgentId) + .filter(|id| self.world.agent(*id).is_some()) + .unwrap_or_else(|| AgentId(self.next_agent_id)); + if self.world.agent(id).is_none() { + 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.next_agent_id = self.next_agent_id.max(id.0.saturating_add(1)); self.agents.insert( stored.client_id, ActiveAgent { @@ -463,8 +489,53 @@ impl SimulationLoop { fn run_simulation_loop(state: AppState) { let mut simulation = SimulationLoop::new(); + publish_world_snapshot(&state, &simulation); loop { - thread::sleep(Duration::from_millis(100)); + let interval = if state.game_running.load(Ordering::SeqCst) { + state.tick_interval_ms.load(Ordering::SeqCst) + } else { + 100 + }; + let mut elapsed = 0; + while elapsed < interval { + let slice = (interval - elapsed).min(100); + thread::sleep(Duration::from_millis(slice)); + elapsed += slice; + if state + .world_command + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + { + break; + } + } + let command = state + .world_command + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(command) = command { + simulation.world = command.world; + simulation.agents.clear(); + simulation.preferred_agent_ids = command.agent_ids; + simulation.next_agent_id = simulation + .world + .agents() + .map(|agent| agent.id().0.saturating_add(1)) + .max() + .unwrap_or(1); + state + .simulation_tick + .store(simulation.world.tick(), Ordering::SeqCst); + publish_world_snapshot(&state, &simulation); + publish_state_event( + &state, + "world_reset", + serde_json::json!({ "tick": simulation.world.tick() }), + ); + continue; + } if !state.game_running.load(Ordering::SeqCst) { continue; } @@ -521,6 +592,7 @@ fn run_simulation_loop(state: AppState) { serde_json::json!({ "id": agent.id().0, "x": x, "y": y }) }) .collect::>(); + publish_world_snapshot(&state, &simulation); publish_state_event( &state, "simulation_tick", @@ -529,6 +601,21 @@ fn run_simulation_loop(state: AppState) { } } +fn publish_world_snapshot(state: &AppState, simulation: &SimulationLoop) { + let snapshot = SimulationWorldSnapshot { + world: simulation.world.clone(), + agent_ids: simulation + .agents + .iter() + .map(|(client_id, agent)| (client_id.clone(), agent.id.0)) + .collect(), + }; + *state + .world_snapshot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(snapshot); +} + fn load_simulation_inputs( state: &AppState, ) -> Result<(Vec, Option, SimulationSettings), String> { @@ -615,17 +702,31 @@ fn activate_queued_agents(state: &AppState) -> Result { } fn read_simulation_settings(database: &Connection) -> Result { - let (ticks_per_day, days_per_week, days_per_month, activation): (u32, u32, u32, String) = - database.query_row( - "SELECT ticks_per_day, days_per_week, days_per_month, agent_activation + let (ticks_per_day, days_per_week, days_per_month, tick_interval_ms, activation): ( + u32, + u32, + u32, + u32, + String, + ) = database.query_row( + "SELECT ticks_per_day, days_per_week, days_per_month, tick_interval_ms, agent_activation FROM simulation_settings WHERE id = 1", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - )?; + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + )?; Ok(SimulationSettings { ticks_per_day, days_per_week, days_per_month, + tick_interval_ms, agent_activation: AgentActivationMode::parse(&activation) .unwrap_or(AgentActivationMode::BetweenTicks), }) @@ -637,11 +738,12 @@ fn save_simulation_settings( ) -> Result<(), rusqlite::Error> { database.execute( "UPDATE simulation_settings SET ticks_per_day = ?1, days_per_week = ?2, - days_per_month = ?3, agent_activation = ?4 WHERE id = 1", + days_per_month = ?3, tick_interval_ms = ?4, agent_activation = ?5 WHERE id = 1", params![ settings.ticks_per_day, settings.days_per_week, settings.days_per_month, + settings.tick_interval_ms, settings.agent_activation.as_str(), ], )?; @@ -708,14 +810,26 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box [], |row| row.get(0), )?; + let tick_interval_ms = read_simulation_settings(&database)?.tick_interval_ms; + let admin_bundle_path = data_directory.join("keys/admin-identity.json"); + let admin_private_key = fs::read_to_string(&pki.admin_key_path)?; + let admin_bundle = serde_json::to_vec_pretty(&serde_json::json!({ + "username": "admin", + "client_id": admin_id, + "private_key_pem": admin_private_key, + }))?; + write_private_file(&admin_bundle_path, &admin_bundle)?; eprintln!("Admin identity ID: {}", admin_id); - eprintln!("Admin private key: {}", pki.admin_key_path.display()); + eprintln!("Admin identity bundle: {}", admin_bundle_path.display()); let state = AppState { database: Arc::new(Mutex::new(database)), game_running: Arc::new(AtomicBool::new(true)), simulation_tick: Arc::new(AtomicU64::new(0)), + tick_interval_ms: Arc::new(AtomicU64::new(u64::from(tick_interval_ms))), + world_snapshot: Arc::new(Mutex::new(None)), + world_command: Arc::new(Mutex::new(None)), event_sender: broadcast::channel(256).0, event_sequence: Arc::new(Mutex::new(0)), }; @@ -733,7 +847,10 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box .route("/api/auth/login", post(auth_login)) .route("/api/me", get(current_identity)) .route("/api/users", get(list_users)) + .route("/api/set_role", post(set_role)) .route("/api/status", get(game_status)) + .route("/api/state", get(download_state).post(restore_state)) + .route("/api/reset_world", post(reset_world)) .route( "/api/settings", get(get_simulation_settings).put(update_simulation_settings), @@ -745,7 +862,7 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box .route("/api/stop_game", post(stop_game)) .route("/api/update_agent", post(update_agent)) .route("/api/upload_core", post(upload_core)) - .layer(axum::extract::DefaultBodyLimit::max(MAX_WASM_BYTES)) + .layer(axum::extract::DefaultBodyLimit::max(MAX_STATE_BYTES)) .with_state(state); eprintln!("Wolloseum HTTP listening on http://{address}"); @@ -773,6 +890,7 @@ fn initialize_database( public_key BLOB NOT NULL, certificate_pem TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, + is_mod INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS used_nonces ( @@ -798,6 +916,7 @@ fn initialize_database( ticks_per_day INTEGER NOT NULL, days_per_week INTEGER NOT NULL, days_per_month INTEGER NOT NULL, + tick_interval_ms INTEGER NOT NULL DEFAULT 100, agent_activation TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS core_module ( @@ -819,19 +938,41 @@ fn initialize_database( last_used INTEGER NOT NULL );", )?; + let has_moderator_role: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info('identities') WHERE name = 'is_mod')", + [], + |row| row.get(0), + )?; + if !has_moderator_role { + connection.execute( + "ALTER TABLE identities ADD COLUMN is_mod INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + let has_tick_interval: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info('simulation_settings') WHERE name = 'tick_interval_ms')", + [], + |row| row.get(0), + )?; + if !has_tick_interval { + connection.execute( + "ALTER TABLE simulation_settings ADD COLUMN tick_interval_ms INTEGER NOT NULL DEFAULT 100", + [], + )?; + } connection.execute( "INSERT OR IGNORE INTO simulation_settings - (id, ticks_per_day, days_per_week, days_per_month, agent_activation) - VALUES (1, 100, 7, 30, 'between_ticks')", + (id, ticks_per_day, days_per_week, days_per_month, tick_interval_ms, agent_activation) + VALUES (1, 86400, 7, 30, 100, 'between_ticks')", [], )?; connection.execute( - "INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active) - VALUES (?1, 'admin', ?2, '', 1, 1) + "INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, is_mod, active) + VALUES (?1, 'admin', ?2, '', 1, 0, 1) ON CONFLICT(id) DO UPDATE SET public_key = excluded.public_key, - is_admin = 1, active = 1 + is_admin = 1, is_mod = 0, active = 1 ON CONFLICT(username) DO UPDATE SET public_key = excluded.public_key, - is_admin = 1, active = 1", + is_admin = 1, is_mod = 0, active = 1", params![admin_id, admin_public_key], )?; Ok(connection) @@ -910,6 +1051,7 @@ struct IdentityInfo { client_id: String, username: String, is_admin: bool, + is_mod: bool, has_agent: bool, agent_sha256: Option, queued_agent_sha256: Option, @@ -925,6 +1067,34 @@ struct GameStatus { running: bool, } +#[derive(Clone, Deserialize, Serialize)] +struct StateBundle { + version: u32, + running: bool, + settings: SimulationSettings, + world: SimulationWorldSnapshot, + users: Vec, + agents: Vec, + queued_agents: Vec, + core_wasm: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +struct BundleIdentity { + client_id: String, + username: String, + public_key: String, + is_admin: bool, + is_mod: bool, + active: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +struct BundleWasm { + client_id: String, + wasm: String, +} + async fn get_simulation_settings( State(state): State, headers: HeaderMap, @@ -947,6 +1117,9 @@ async fn update_simulation_settings( let settings = settings.validate().map_err(ApiError::bad_request)?; let database = state.database.lock().map_err(ApiError::internal)?; save_simulation_settings(&database, settings).map_err(ApiError::internal)?; + state + .tick_interval_ms + .store(u64::from(settings.tick_interval_ms), Ordering::SeqCst); publish_state_event( &state, "simulation_settings_updated", @@ -1038,6 +1211,30 @@ fn authenticate( Ok(client_id.to_string()) } +fn authenticate_moderator( + state: &AppState, + headers: &HeaderMap, + method: &Method, + path: &str, + body: &[u8], +) -> Result { + let client_id = authenticate(state, headers, method, path, body, false)?; + let database = state.database.lock().map_err(ApiError::internal)?; + let (is_admin, is_mod) = database + .query_row( + "SELECT is_admin, is_mod FROM identities WHERE id = ?1 AND active = 1", + [&client_id], + |row| Ok((row.get::<_, bool>(0)?, row.get::<_, bool>(1)?)), + ) + .optional() + .map_err(ApiError::internal)? + .ok_or_else(ApiError::unauthorized)?; + if !is_admin && !is_mod { + return Err(ApiError::forbidden()); + } + Ok(client_id) +} + fn authenticate_session( state: &AppState, token: &str, @@ -1114,14 +1311,7 @@ async fn create_user( headers: HeaderMap, body: Bytes, ) -> Result, ApiError> { - authenticate( - &state, - &headers, - &Method::POST, - "/api/create_user", - &body, - true, - )?; + authenticate_moderator(&state, &headers, &Method::POST, "/api/create_user", &body)?; let request: CreateUserRequest = serde_json::from_slice(&body).map_err(|error| ApiError::bad_request(error.to_string()))?; let username = request.username.trim(); @@ -1172,23 +1362,26 @@ async fn delete_user( headers: HeaderMap, body: Bytes, ) -> Result { - authenticate( - &state, - &headers, - &Method::POST, - "/api/delete_user", - &body, - true, - )?; + let actor_id = + authenticate_moderator(&state, &headers, &Method::POST, "/api/delete_user", &body)?; let request: DeleteUserRequest = serde_json::from_slice(&body).map_err(|error| ApiError::bad_request(error.to_string()))?; let database = state.database.lock().map_err(ApiError::internal)?; - let changed = database - .execute( - "DELETE FROM identities WHERE id = ?1 AND is_admin = 0", - [&request.client_id], + let is_admin: bool = database + .query_row( + "SELECT is_admin FROM identities WHERE id = ?1", + [&actor_id], + |row| row.get(0), ) .map_err(ApiError::internal)?; + let condition = if is_admin { + "DELETE FROM identities WHERE id = ?1 AND is_admin = 0" + } else { + "DELETE FROM identities WHERE id = ?1 AND is_admin = 0 AND is_mod = 0" + }; + let changed = database + .execute(condition, [&request.client_id]) + .map_err(ApiError::internal)?; if changed == 0 { return Err(ApiError( StatusCode::NOT_FOUND, @@ -1199,6 +1392,51 @@ async fn delete_user( Ok(StatusCode::NO_CONTENT) } +#[derive(Deserialize)] +struct SetRoleRequest { + client_id: String, + role: String, +} + +async fn set_role( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + authenticate( + &state, + &headers, + &Method::POST, + "/api/set_role", + &body, + true, + )?; + let request: SetRoleRequest = + serde_json::from_slice(&body).map_err(|error| ApiError::bad_request(error.to_string()))?; + let (is_admin, is_mod) = match request.role.as_str() { + "user" => (false, false), + "mod" => (false, true), + "admin" => (true, false), + _ => return Err(ApiError::bad_request("role must be user, mod, or admin")), + }; + let database = state.database.lock().map_err(ApiError::internal)?; + let changed = database + .execute( + "UPDATE identities SET is_admin = ?1, is_mod = ?2 + WHERE id = ?3 AND username != 'admin'", + params![is_admin, is_mod, request.client_id], + ) + .map_err(ApiError::internal)?; + if changed == 0 { + return Err(ApiError( + StatusCode::NOT_FOUND, + "user not found or protected".to_string(), + )); + } + publish_state_event(&state, "user_role_changed", serde_json::json!({})); + Ok(StatusCode::NO_CONTENT) +} + async fn stop_game( State(state): State, headers: HeaderMap, @@ -1337,6 +1575,324 @@ async fn upload_core( )) } +async fn download_state( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + authenticate(&state, &headers, &Method::GET, "/api/state", b"", true)?; + let database = state.database.lock().map_err(ApiError::internal)?; + let settings = read_simulation_settings(&database).map_err(ApiError::internal)?; + let users = { + let mut statement = database + .prepare("SELECT id, username, public_key, is_admin, is_mod, active FROM identities ORDER BY id") + .map_err(ApiError::internal)?; + statement + .query_map([], |row| { + Ok(BundleIdentity { + client_id: row.get(0)?, + username: row.get(1)?, + public_key: BASE64.encode(row.get::<_, Vec>(2)?), + is_admin: row.get(3)?, + is_mod: row.get(4)?, + active: row.get(5)?, + }) + }) + .map_err(ApiError::internal)? + .collect::, _>>() + .map_err(ApiError::internal)? + }; + let agents = read_bundle_wasm(&database, "agents")?; + let queued_agents = read_bundle_wasm(&database, "queued_agents")?; + let core_wasm = database + .query_row("SELECT wasm FROM core_module WHERE id = 1", [], |row| { + row.get::<_, Vec>(0) + }) + .optional() + .map_err(ApiError::internal)? + .map(|wasm| BASE64.encode(wasm)); + let world = state + .world_snapshot + .lock() + .map_err(ApiError::internal)? + .clone() + .unwrap_or_else(|| SimulationWorldSnapshot { + world: World::generate(0x574F4C4C, 32, 32).expect("default world dimensions are valid"), + agent_ids: BTreeMap::new(), + }); + Ok(Json(StateBundle { + version: 1, + running: state.game_running.load(Ordering::SeqCst), + settings, + world, + users, + agents, + queued_agents, + core_wasm, + })) +} + +fn read_bundle_wasm(database: &Connection, table: &str) -> Result, ApiError> { + let query = format!("SELECT identity_id, wasm FROM {table} ORDER BY identity_id"); + let mut statement = database.prepare(&query).map_err(ApiError::internal)?; + statement + .query_map([], |row| { + Ok(BundleWasm { + client_id: row.get(0)?, + wasm: BASE64.encode(row.get::<_, Vec>(1)?), + }) + }) + .map_err(ApiError::internal)? + .collect::, _>>() + .map_err(ApiError::internal) +} + +async fn restore_state( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + let actor_id = authenticate(&state, &headers, &Method::POST, "/api/state", &body, true)?; + if body.len() > MAX_STATE_BYTES { + return Err(ApiError( + StatusCode::PAYLOAD_TOO_LARGE, + "state bundle exceeds 64 MiB".to_string(), + )); + } + let bundle: StateBundle = + serde_json::from_slice(&body).map_err(|error| ApiError::bad_request(error.to_string()))?; + if bundle.version != 1 { + return Err(ApiError::bad_request("unsupported state bundle version")); + } + let settings = bundle.settings.validate().map_err(ApiError::bad_request)?; + bundle + .world + .world + .validate() + .map_err(|error| ApiError::bad_request(error.to_string()))?; + let database = state.database.lock().map_err(ApiError::internal)?; + let actor_key: Vec = database + .query_row( + "SELECT public_key FROM identities WHERE id = ?1", + [&actor_id], + |row| row.get(0), + ) + .map_err(ApiError::internal)?; + let actor_sessions = { + let mut statement = database + .prepare( + "SELECT token_hash, expires_at, last_used FROM sessions WHERE identity_id = ?1", + ) + .map_err(ApiError::internal)?; + statement + .query_map([&actor_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(ApiError::internal)? + .collect::, _>>() + .map_err(ApiError::internal)? + }; + let mut identities = BTreeMap::new(); + let mut usernames = BTreeSet::new(); + for user in &bundle.users { + if user.username.is_empty() + || user.username.len() > 32 + || !user + .username + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_".contains(&byte)) + || !usernames.insert(user.username.to_ascii_lowercase()) + { + return Err(ApiError::bad_request( + "state bundle contains an invalid or duplicate username", + )); + } + let public_key = BASE64 + .decode(&user.public_key) + .map_err(|_| ApiError::bad_request("state bundle contains an invalid public key"))?; + let is_legacy_current_admin = user.client_id == actor_id && user.is_admin; + if public_key.len() != 32 + || (public_key_id(&public_key) != user.client_id && !is_legacy_current_admin) + || (user.is_admin && user.is_mod) + { + return Err(ApiError::bad_request( + "state bundle identity fingerprint does not match its public key", + )); + } + if identities + .insert(user.client_id.clone(), public_key) + .is_some() + { + return Err(ApiError::bad_request( + "state bundle contains duplicate identity IDs", + )); + } + } + let restoring_admin = bundle.users.iter().find(|user| user.client_id == actor_id); + if !restoring_admin.is_some_and(|user| user.is_admin && user.active) + || identities.get(&actor_id) != Some(&actor_key) + || !bundle.users.iter().any(|user| user.is_admin && user.active) + { + return Err(ApiError::bad_request( + "state bundle must retain the active administrator performing the restore", + )); + } + let mut active_wasm = BTreeSet::new(); + let mut queued_wasm = BTreeSet::new(); + for (modules, seen) in [ + (&bundle.agents, &mut active_wasm), + (&bundle.queued_agents, &mut queued_wasm), + ] { + for module in modules { + if !identities.contains_key(&module.client_id) || !seen.insert(module.client_id.clone()) + { + return Err(ApiError::bad_request( + "state bundle contains an orphaned or duplicate agent", + )); + } + validate_agent_bytes( + &BASE64.decode(&module.wasm).map_err(|_| { + ApiError::bad_request("state bundle contains invalid agent data") + })?, + ) + .map_err(ApiError::bad_request)?; + } + } + let mut bound_ids = BTreeSet::new(); + for (client_id, agent_id) in &bundle.world.agent_ids { + if !active_wasm.contains(client_id) + || !bound_ids.insert(*agent_id) + || bundle.world.world.agent(AgentId(*agent_id)).is_none() + { + return Err(ApiError::bad_request( + "state bundle has invalid world agent bindings", + )); + } + } + if bundle.world.world.agents().count() != bundle.world.agent_ids.len() { + return Err(ApiError::bad_request( + "state bundle is missing world agent bindings", + )); + } + let core_bytes = bundle + .core_wasm + .as_ref() + .map(|encoded| { + BASE64 + .decode(encoded) + .map_err(|_| ApiError::bad_request("state bundle contains invalid core data")) + }) + .transpose()?; + if let Some(bytes) = &core_bytes { + if bytes.len() > MAX_WASM_BYTES { + return Err(ApiError::bad_request("state bundle core exceeds 1 MiB")); + } + CoreRuntime::from_wasm(bytes).map_err(ApiError::bad_request)?; + } + let transaction = database + .unchecked_transaction() + .map_err(ApiError::internal)?; + transaction + .execute("DELETE FROM identities", []) + .map_err(ApiError::internal)?; + for user in &bundle.users { + transaction + .execute( + "INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, is_mod, active) + VALUES (?1, ?2, ?3, '', ?4, ?5, ?6)", + params![user.client_id, user.username, identities[&user.client_id], user.is_admin, user.is_mod, user.active], + ) + .map_err(ApiError::internal)?; + } + for (token_hash, expires_at, last_used) in actor_sessions { + transaction + .execute( + "INSERT INTO sessions (token_hash, identity_id, expires_at, last_used) VALUES (?1, ?2, ?3, ?4)", + params![token_hash, actor_id, expires_at, last_used], + ) + .map_err(ApiError::internal)?; + } + for (table, modules) in [ + ("agents", &bundle.agents), + ("queued_agents", &bundle.queued_agents), + ] { + for module in modules { + let wasm = BASE64.decode(&module.wasm).map_err(ApiError::internal)?; + transaction + .execute( + &format!("INSERT INTO {table} (identity_id, wasm, sha256, uploaded_at) VALUES (?1, ?2, ?3, ?4)"), + params![module.client_id, wasm, digest_hex(&wasm), unix_time() as i64], + ) + .map_err(ApiError::internal)?; + } + } + transaction + .execute("DELETE FROM core_module", []) + .map_err(ApiError::internal)?; + if let Some(wasm) = core_bytes { + transaction + .execute( + "INSERT INTO core_module (id, wasm, sha256, uploaded_at) VALUES (1, ?1, ?2, ?3)", + params![wasm, digest_hex(&wasm), unix_time() as i64], + ) + .map_err(ApiError::internal)?; + } + save_simulation_settings(&transaction, settings).map_err(ApiError::internal)?; + transaction.commit().map_err(ApiError::internal)?; + state + .tick_interval_ms + .store(u64::from(settings.tick_interval_ms), Ordering::SeqCst); + set_game_running(&state, bundle.running); + *state.world_command.lock().map_err(ApiError::internal)? = Some(bundle.world); + publish_state_event(&state, "state_restored", serde_json::json!({})); + Ok(StatusCode::NO_CONTENT) +} + +fn validate_agent_bytes(bytes: &[u8]) -> Result<(), String> { + if bytes.len() > MAX_WASM_BYTES { + return Err("state bundle agent exceeds 1 MiB".to_string()); + } + let mut agent = WasmAgent::load(bytes).map_err(|error| error.to_string())?; + agent + .decide(AgentObservation { + tick: 0, + x: 0, + y: 0, + adjacent: [ObservedTerrain::Boundary; 4], + }) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +async fn reset_world( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + authenticate( + &state, + &headers, + &Method::POST, + "/api/reset_world", + &body, + true, + )?; + let mut seed_bytes = [0u8; 8]; + SystemRandom::new() + .fill(&mut seed_bytes) + .map_err(|_| ApiError::internal("secure random generation failed"))?; + let seed = u64::from_le_bytes(seed_bytes); + let world = World::generate(seed, 32, 32).map_err(ApiError::internal)?; + *state.world_command.lock().map_err(ApiError::internal)? = Some(SimulationWorldSnapshot { + world, + agent_ids: BTreeMap::new(), + }); + Ok(StatusCode::ACCEPTED) +} + async fn game_status(State(state): State) -> Json { Json(GameStatus { running: state.game_running.load(Ordering::SeqCst), @@ -1525,6 +2081,25 @@ fn write_new_file(path: &Path, contents: &[u8], private: bool) -> Result<(), Box Ok(()) } +fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), Box> { + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(contents)?; + file.sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + #[cfg(unix)] fn set_private_directory_permissions(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; @@ -1577,12 +2152,60 @@ mod tests { database: Arc::new(Mutex::new(database)), game_running: Arc::new(AtomicBool::new(true)), simulation_tick: Arc::new(AtomicU64::new(0)), + tick_interval_ms: Arc::new(AtomicU64::new(100)), + world_snapshot: Arc::new(Mutex::new(None)), + world_command: Arc::new(Mutex::new(None)), event_sender: tokio::sync::broadcast::channel(256).0, event_sequence: Arc::new(Mutex::new(0)), }; (state, admin_id, admin_key) } + #[test] + fn existing_database_schema_migrates_roles_and_tick_interval() { + let directory = tempfile::tempdir().unwrap(); + let pki = Pki::initialize(directory.path()).unwrap(); + let database_path = directory.path().join("legacy.sqlite3"); + let legacy = rusqlite::Connection::open(&database_path).unwrap(); + legacy + .execute_batch( + "CREATE TABLE identities ( + id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, certificate_pem TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE simulation_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + ticks_per_day INTEGER NOT NULL, days_per_week INTEGER NOT NULL, + days_per_month INTEGER NOT NULL, agent_activation TEXT NOT NULL + ); + INSERT INTO simulation_settings VALUES (1, 86400, 7, 30, 'between_ticks');", + ) + .unwrap(); + drop(legacy); + + let database = + super::initialize_database(&database_path, &pki.admin_id, &pki.admin_public_key) + .unwrap(); + let (is_admin, is_mod): (bool, bool) = database + .query_row( + "SELECT is_admin, is_mod FROM identities WHERE id = ?1", + [&pki.admin_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + let interval: u32 = database + .query_row( + "SELECT tick_interval_ms FROM simulation_settings WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(is_admin); + assert!(!is_mod); + assert_eq!(interval, 100); + } + fn signed_headers( client_id: &str, key: &KeyPair, @@ -1654,6 +2277,7 @@ mod tests { ticks_per_day: 100, days_per_week: 2, days_per_month: 3, + tick_interval_ms: 100, agent_activation: AgentActivationMode::BetweenTicks, }; assert!(queued_agents_should_activate(0, settings)); @@ -1706,6 +2330,14 @@ mod tests { .validate() .is_err() ); + assert!( + SimulationSettings { + tick_interval_ms: 0, + ..SimulationSettings::default() + } + .validate() + .is_err() + ); } #[tokio::test] @@ -1723,13 +2355,15 @@ mod tests { let Json(defaults) = super::get_simulation_settings(State(state.clone()), read_headers) .await .unwrap(); - assert_eq!(defaults.ticks_per_day, 100); + assert_eq!(defaults.ticks_per_day, 86400); + assert_eq!(defaults.tick_interval_ms, 100); assert_eq!(defaults.agent_activation, AgentActivationMode::BetweenTicks); let body = serde_json::to_vec(&serde_json::json!({ "ticks_per_day": 240, "days_per_week": 6, "days_per_month": 28, + "tick_interval_ms": 321, "agent_activation": "between_weeks", })) .unwrap(); @@ -1751,7 +2385,258 @@ mod tests { assert_eq!(updated.ticks_per_day, 240); assert_eq!(updated.days_per_week, 6); assert_eq!(updated.days_per_month, 28); + assert_eq!(updated.tick_interval_ms, 321); assert_eq!(updated.agent_activation, AgentActivationMode::BetweenWeeks); + assert_eq!( + state + .tick_interval_ms + .load(std::sync::atomic::Ordering::SeqCst), + 321 + ); + } + + #[tokio::test] + async fn moderators_can_manage_players_but_not_admin_controls() { + let directory = tempfile::tempdir().unwrap(); + let (state, admin_id, admin_key) = test_state(directory.path()); + let moderator_key = KeyPair::generate_for(&PKCS_ED25519).unwrap(); + let create_body = serde_json::to_vec(&serde_json::json!({ + "username": "moderator", + "public_key": BASE64.encode(moderator_key.public_key_raw()), + })) + .unwrap(); + let create_headers = signed_headers( + &admin_id, + &admin_key, + &Method::POST, + "/api/create_user", + &create_body, + "admin-create-moderator", + ); + let Json(moderator) = super::create_user( + State(state.clone()), + create_headers, + Bytes::from(create_body), + ) + .await + .unwrap(); + let role_body = serde_json::to_vec(&serde_json::json!({ + "client_id": moderator.client_id, + "role": "mod", + })) + .unwrap(); + let role_headers = signed_headers( + &admin_id, + &admin_key, + &Method::POST, + "/api/set_role", + &role_body, + "admin-set-moderator", + ); + super::set_role(State(state.clone()), role_headers, Bytes::from(role_body)) + .await + .unwrap(); + + let list_headers = signed_headers( + &moderator.client_id, + &moderator_key, + &Method::GET, + "/api/users", + b"", + "moderator-list-users", + ); + let Json(users) = super::list_users(State(state.clone()), list_headers) + .await + .unwrap(); + assert!( + users + .iter() + .any(|user| user.client_id == moderator.client_id && user.is_mod) + ); + + let player_key = KeyPair::generate_for(&PKCS_ED25519).unwrap(); + let player_body = serde_json::to_vec(&serde_json::json!({ + "username": "mod-created-player", + "public_key": BASE64.encode(player_key.public_key_raw()), + })) + .unwrap(); + let player_headers = signed_headers( + &moderator.client_id, + &moderator_key, + &Method::POST, + "/api/create_user", + &player_body, + "moderator-create-player", + ); + let Json(player) = super::create_user( + State(state.clone()), + player_headers, + Bytes::from(player_body), + ) + .await + .unwrap(); + let delete_body = + serde_json::to_vec(&serde_json::json!({ "client_id": player.client_id })).unwrap(); + let delete_headers = signed_headers( + &moderator.client_id, + &moderator_key, + &Method::POST, + "/api/delete_user", + &delete_body, + "moderator-delete-player", + ); + super::delete_user( + State(state.clone()), + delete_headers, + Bytes::from(delete_body), + ) + .await + .unwrap(); + + let settings_headers = signed_headers( + &moderator.client_id, + &moderator_key, + &Method::GET, + "/api/settings", + b"", + "moderator-settings-denied", + ); + assert!( + super::get_simulation_settings(State(state), settings_headers) + .await + .is_err() + ); + } + + #[tokio::test] + async fn state_restore_and_world_reset_preserve_the_expected_records() { + let directory = tempfile::tempdir().unwrap(); + let (state, admin_id, admin_key) = test_state(directory.path()); + state + .database + .lock() + .unwrap() + .execute( + "INSERT INTO sessions (token_hash, identity_id, expires_at, last_used) VALUES ('session-hash', ?1, ?2, ?3)", + rusqlite::params![admin_id, unix_time() as i64 + 3600, unix_time() as i64], + ) + .unwrap(); + let download_headers = signed_headers( + &admin_id, + &admin_key, + &Method::GET, + "/api/state", + b"", + "admin-download-state", + ); + let Json(mut bundle) = super::download_state(State(state.clone()), download_headers) + .await + .unwrap(); + bundle.settings.tick_interval_ms = 321; + bundle.running = false; + bundle.world.world = super::World::generate(999, 32, 32).unwrap(); + bundle.world.agent_ids.clear(); + let restore_body = serde_json::to_vec(&bundle).unwrap(); + let restore_headers = signed_headers( + &admin_id, + &admin_key, + &Method::POST, + "/api/state", + &restore_body, + "admin-restore-state", + ); + super::restore_state( + State(state.clone()), + restore_headers, + Bytes::from(restore_body), + ) + .await + .unwrap(); + + assert_eq!( + state + .tick_interval_ms + .load(std::sync::atomic::Ordering::SeqCst), + 321 + ); + assert!(!state.game_running.load(std::sync::atomic::Ordering::SeqCst)); + let command = state.world_command.lock().unwrap().clone().unwrap(); + assert_eq!(command.world.seed(), 999); + let user_count: i64 = state + .database + .lock() + .unwrap() + .query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0)) + .unwrap(); + assert_eq!(user_count, 1); + let retained_session_count: i64 = state + .database + .lock() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM sessions WHERE token_hash = 'session-hash'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retained_session_count, 1); + { + let database = state.database.lock().unwrap(); + database + .execute( + "INSERT INTO agents (identity_id, wasm, sha256, uploaded_at) VALUES (?1, ?2, 'agent-hash', 1)", + rusqlite::params![admin_id, b"retained-agent"], + ) + .unwrap(); + database + .execute( + "INSERT INTO core_module (id, wasm, sha256, uploaded_at) VALUES (1, ?1, 'core-hash', 1)", + [b"retained-core"], + ) + .unwrap(); + } + + let reset_headers = signed_headers( + &admin_id, + &admin_key, + &Method::POST, + "/api/reset_world", + b"", + "admin-reset-world", + ); + super::reset_world(State(state.clone()), reset_headers, Bytes::new()) + .await + .unwrap(); + let reset = state.world_command.lock().unwrap().clone().unwrap(); + assert_ne!(reset.world.seed(), 999); + assert_eq!(reset.world.tick(), 0); + assert!(reset.world.agents().next().is_none()); + let tick_interval: u32 = state + .database + .lock() + .unwrap() + .query_row( + "SELECT tick_interval_ms FROM simulation_settings WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tick_interval, 321); + let database = state.database.lock().unwrap(); + let agent_wasm: Vec = database + .query_row( + "SELECT wasm FROM agents WHERE identity_id = ?1", + [&admin_id], + |row| row.get(0), + ) + .unwrap(); + let core_wasm: Vec = database + .query_row("SELECT wasm FROM core_module WHERE id = 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(agent_wasm, b"retained-agent"); + assert_eq!(core_wasm, b"retained-core"); } #[tokio::test] diff --git a/src/wasm_agent.rs b/src/wasm_agent.rs index 0818021..83bdbac 100644 --- a/src/wasm_agent.rs +++ b/src/wasm_agent.rs @@ -112,6 +112,14 @@ impl WasmAgent { } } +impl agent_api::Agent for WasmAgent { + type Error = WasmAgentError; + + fn decide(&mut self, observation: AgentObservation) -> Result { + WasmAgent::decide(self, observation) + } +} + fn terrain_code(terrain: ObservedTerrain) -> i32 { terrain as i32 } diff --git a/web/admin.html b/web/admin.html index 0103ae1..9313252 100644 --- a/web/admin.html +++ b/web/admin.html @@ -6,6 +6,7 @@ Wolloseum | Administration + @@ -14,22 +15,19 @@
W WOLLOSEUM + aria-current="page">Admin
-
+

Core simulation

SANDBOX VALIDATED
@@ -80,16 +78,23 @@
-
WOLLOSEUM | ADMINPrivileged actions require an admin identity
+
WOLLOSEUM | ADMINModerator user management | Administrator server control
diff --git a/web/app.css b/web/app.css index 692e40a..e6b69b5 100644 --- a/web/app.css +++ b/web/app.css @@ -16,6 +16,36 @@ --sans: 'Manrope', sans-serif; } +:root[data-theme="dark"] { + color-scheme: dark; + --ink: #e8efe9; + --muted: #92a299; + --line: #2b3934; + --paper: #11201a; + --white: #152620; + --green: #4fb892; + --green-dark: #39997a; + --lime: #d5ef78; + --coral: #e08871; + --blue: #6fb0c2; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --ink: #e8efe9; + --muted: #92a299; + --line: #2b3934; + --paper: #11201a; + --white: #152620; + --green: #4fb892; + --green-dark: #39997a; + --lime: #d5ef78; + --coral: #e08871; + --blue: #6fb0c2; + } +} + * { box-sizing: border-box; } @@ -25,9 +55,10 @@ body { min-height: 100vh; color: var(--ink); background-color: var(--paper); - background-image: radial-gradient(#dce4dc 0.7px, transparent 0.7px); + background-image: radial-gradient(color-mix(in srgb, var(--line) 70%, transparent) 0.7px, transparent 0.7px); background-size: 18px 18px; font: 15px/1.55 var(--sans); + transition: background-color 150ms ease, color 150ms ease; } button, @@ -161,13 +192,13 @@ h3 { width: 7px; height: 7px; border-radius: 50%; - background: #98a39d; + background: var(--muted); } .status-chip.live .status-dot, .status-chip.running .status-dot { - background: #48a674; - box-shadow: 0 0 0 3px #48a67424; + background: var(--green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 25%, transparent); } .status-chip.paused .status-dot, @@ -254,17 +285,17 @@ h3 { width: 100%; min-height: 42px; padding: 9px 11px; - border: 1px solid #cbd5ce; + border: 1px solid var(--line); border-radius: 4px; color: var(--ink); - background: #fff; + background: var(--white); outline: 0; } .field input:focus, .field select:focus { border-color: var(--green); - box-shadow: 0 0 0 3px #176b5018; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 20%, transparent); } .field input[type="file"] { @@ -308,16 +339,16 @@ h3 { .button.secondary { color: var(--ink); border-color: var(--line); - background: white; + background: var(--white); } .button.secondary:hover { - background: #f2f5f0; + background: color-mix(in srgb, var(--white) 85%, var(--ink) 15%); } .button.danger { - border-color: #b64e3d; - background: #b64e3d; + border-color: var(--coral); + background: var(--coral); } .button:disabled { @@ -340,7 +371,7 @@ h3 { } .message.error { - color: #a43d30; + color: var(--coral); } .message.success { @@ -358,7 +389,7 @@ h3 { padding: 16px; border: 1px solid var(--line); border-radius: 5px; - background: #fbfcf8; + background: color-mix(in srgb, var(--white) 92%, var(--paper) 8%); } .stat-label { @@ -403,13 +434,24 @@ h3 { align-items: center; gap: 16px; padding: 14px 20px; - border-bottom: 1px solid #e8ece7; + border-bottom: 1px solid var(--line); } .user-row:last-child { border-bottom: 0; } +.user-row select { + min-height: 36px; + max-width: 180px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--ink); + background: var(--white); + font-size: 12px; +} + .user-name { font-weight: 800; } @@ -451,7 +493,7 @@ h3 { grid-template-columns: 68px minmax(120px, 180px) minmax(0, 1fr); gap: 14px; padding: 13px 17px; - border-bottom: 1px solid #e8ece7; + border-bottom: 1px solid var(--line); animation: arrive 180ms ease-out both; } @@ -472,7 +514,7 @@ h3 { } .event-data { - color: #53615c; + color: color-mix(in srgb, var(--ink) 75%, var(--muted) 25%); font: 10px/1.5 var(--mono); overflow-wrap: anywhere; } @@ -514,7 +556,7 @@ h3 { border: 1px solid var(--line); border-radius: 4px; color: var(--green-dark); - background: #f5f8f2; + background: color-mix(in srgb, var(--white) 90%, var(--green) 10%); font: 10px/1.5 var(--mono); } @@ -524,7 +566,7 @@ h3 { .accent-band { height: 4px; - background: linear-gradient(90deg, var(--green) 0 62%, var(--lime) 62% 82%, #e7a28b 82%); + background: linear-gradient(90deg, var(--green) 0 62%, var(--lime) 62% 82%, var(--coral) 82%); } .viewer-head { diff --git a/web/app.js b/web/app.js index adc49aa..3179756 100644 --- a/web/app.js +++ b/web/app.js @@ -1,7 +1,26 @@ const $ = (selector, root = document) => root.querySelector(selector); const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`; +const THEME_KEY = 'wolloseum.theme'; const encoder = new TextEncoder(); +function currentTheme() { + return document.documentElement.dataset.theme + || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); +} + +function setTheme(theme) { + document.documentElement.dataset.theme = theme; + localStorage.setItem(THEME_KEY, theme); +} + +function bootTheme() { + const toggle = $('#theme-toggle'); + if (!toggle) return; + const paint = () => { toggle.textContent = currentTheme() === 'dark' ? '\u2600' : '\u263D'; }; + toggle.addEventListener('click', () => { setTheme(currentTheme() === 'dark' ? 'light' : 'dark'); paint(); }); + paint(); +} + function setMessage(element, text, kind = '') { if (!element) return; element.textContent = text; @@ -89,19 +108,17 @@ async function api(path, options = {}) { } async function readCredential(file) { - const text = await file.text(); - try { - const bundle = JSON.parse(text); - if (bundle.client_id && bundle.private_key_pem) return bundle; - } catch (_) { } - const identity = $('#identity-id').value.trim(); - if (!identity) throw new Error('Enter the identity ID printed by the server or supplied by your administrator.'); - return { client_id: identity, private_key_pem: text }; + let bundle; + try { bundle = JSON.parse(await file.text()); } + catch (_) { throw new Error('Choose a valid JSON identity bundle.'); } + if (!bundle.client_id || !bundle.private_key_pem) { + throw new Error('This JSON file is not an identity bundle.'); + } + return bundle; } -async function signIn() { - const file = $('#private-key-file').files[0]; - if (!file) throw new Error('Choose your private key file.'); +async function signIn(file) { + if (!file) throw new Error('Choose your identity bundle.'); const credential = await readCredential(file); const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/); if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.'); @@ -217,9 +234,10 @@ function bootLogin() { const account = $('#account-name'); const reveal = async identity => { const requiredAdmin = page === 'admin'; - if (identity.is_admin !== requiredAdmin) { - throw new Error(requiredAdmin ? 'This key does not belong to an administrator.' : 'Use the administration page to sign in with an admin key.'); + if (requiredAdmin && !identity.is_admin && !identity.is_mod) { + throw new Error('This identity does not have moderator or administrator rights.'); } + window.currentIdentity = identity; panel.classList.add('hidden'); app.classList.remove('hidden'); account.textContent = identity.username; @@ -228,22 +246,22 @@ function bootLogin() { }; form.addEventListener('submit', async event => { event.preventDefault(); - const button = $('button[type="submit"]', form); + $('#identity-bundle').click(); + }); + $('#login-button').addEventListener('click', () => $('#identity-bundle').click()); + $('#identity-bundle').addEventListener('change', async event => { + const fileInput = event.currentTarget; + const button = $('#login-button'); button.disabled = true; setMessage(message, 'Verifying challenge...'); - try { await reveal(await signIn()); } + try { await reveal(await signIn(fileInput.files[0])); } catch (error) { setMessage(message, error.message, 'error'); } - finally { button.disabled = false; } + finally { + button.disabled = false; + fileInput.value = ''; + } }); $('#logout-button, #admin-logout').addEventListener('click', logout); - $('#private-key-file').addEventListener('change', async event => { - const file = event.currentTarget.files[0]; - if (!file || !file.name.endsWith('.json')) return; - try { - const bundle = JSON.parse(await file.text()); - if (bundle.client_id) $('#identity-id').value = bundle.client_id; - } catch (_) { } - }); if (localStorage.getItem(TOKEN_KEY)) { api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY)); } @@ -290,10 +308,14 @@ function paintGameStatus(running) { if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED'; } -async function bootAdmin() { +async function bootAdmin(identity) { + document.querySelectorAll('.admin-only').forEach(element => { + element.classList.toggle('hidden', !identity.is_admin); + }); + if (!identity.is_admin) setMessage($('#admin-message'), 'Moderator access | user management only.'); const status = await api('/api/status'); paintGameStatus(status.running); - $('#game-control').addEventListener('click', async () => { + if (identity.is_admin) $('#game-control').addEventListener('click', async () => { const running = $('#game-status-label').textContent === 'RUNNING'; const button = $('#game-control'); button.disabled = true; @@ -304,18 +326,24 @@ async function bootAdmin() { } catch (error) { setMessage($('#admin-message'), error.message, 'error'); } finally { button.disabled = false; } }); - $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation'; + if ($('#game-control')) $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation'; $('#user-form').addEventListener('submit', createUser); - $('#core-form').addEventListener('submit', uploadCore); - $('#simulation-settings-form').addEventListener('submit', saveSimulationSettings); - await loadSimulationSettings(); + if (identity.is_admin) { + $('#core-form').addEventListener('submit', uploadCore); + $('#simulation-settings-form').addEventListener('submit', saveSimulationSettings); + $('#reset-world').addEventListener('click', resetWorld); + $('#export-state').addEventListener('click', exportState); + $('#import-state').addEventListener('click', () => $('#state-file').click()); + $('#state-file').addEventListener('change', importState); + await loadSimulationSettings(); + } await refreshUsers(); connectEvents(event => { if (event.event === 'snapshot' || event.event === 'game_state_changed') { paintGameStatus(event.data.running); - $('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation'; + if ($('#game-control')) $('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation'; } - if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers(); + if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'user_role_changed' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers(); }); } @@ -325,6 +353,7 @@ async function loadSimulationSettings() { $('#ticks-per-day').value = settings.ticks_per_day; $('#days-per-week').value = settings.days_per_week; $('#days-per-month').value = settings.days_per_month; + $('#tick-interval-ms').value = settings.tick_interval_ms; $('#agent-activation').value = settings.agent_activation; } catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); } } @@ -338,6 +367,7 @@ async function saveSimulationSettings(event) { ticks_per_day: Number($('#ticks-per-day').value), days_per_week: Number($('#days-per-week').value), days_per_month: Number($('#days-per-month').value), + tick_interval_ms: Number($('#tick-interval-ms').value), agent_activation: $('#agent-activation').value, }; try { @@ -389,7 +419,7 @@ async function refreshUsers() { const info = document.createElement('div'); const name = document.createElement('div'); name.className = 'user-name'; - name.textContent = `${user.username}${user.is_admin ? ' | admin' : ''}`; + name.textContent = `${user.username}${user.is_admin ? ' | admin' : user.is_mod ? ' | mod' : ''}`; const id = document.createElement('div'); id.className = 'user-id'; id.textContent = user.client_id; @@ -402,7 +432,26 @@ async function refreshUsers() { meta.textContent = agentStatus.join(' | '); info.append(name, id, meta); row.append(info); - if (!user.is_admin) { + if (window.currentIdentity?.is_admin && user.username !== 'admin') { + const role = document.createElement('select'); + role.setAttribute('aria-label', `${user.username} role`); + [['user', 'Player'], ['mod', 'Moderator'], ['admin', 'Administrator']].forEach(([value, label]) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + role.append(option); + }); + role.value = user.is_admin ? 'admin' : user.is_mod ? 'mod' : 'user'; + role.addEventListener('change', async () => { + try { + await api('/api/set_role', { method: 'POST', body: JSON.stringify({ client_id: user.client_id, role: role.value }) }); + await refreshUsers(); + } catch (error) { setMessage($('#admin-message'), error.message, 'error'); } + }); + row.append(role); + } + const mayRemove = !user.is_admin && (window.currentIdentity?.is_admin || !user.is_mod); + if (mayRemove) { const remove = document.createElement('button'); remove.className = 'button danger'; remove.type = 'button'; @@ -422,6 +471,40 @@ async function refreshUsers() { } catch (error) { setMessage($('#admin-message'), error.message, 'error'); } } +async function resetWorld() { + if (!confirm('Reset the world? Settings, users, agents, and core will be kept.')) return; + try { + await api('/api/reset_world', { method: 'POST', body: '' }); + setMessage($('#state-message'), 'World reset requested.', 'success'); + } catch (error) { setMessage($('#state-message'), error.message, 'error'); } +} + +async function exportState() { + try { + const state = await api('/api/state'); + const url = URL.createObjectURL(new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; + link.download = 'wolloseum-state.json'; + link.click(); + URL.revokeObjectURL(url); + setMessage($('#state-message'), 'Simulation state downloaded.', 'success'); + } catch (error) { setMessage($('#state-message'), error.message, 'error'); } +} + +async function importState(event) { + const file = event.currentTarget.files[0]; + if (!file) return; + if (!confirm('Restore this simulation state? Current settings, users, agents, core, and world will be replaced.')) return; + try { + await api('/api/state', { method: 'POST', body: await file.text() }); + setMessage($('#state-message'), 'Simulation state restored.', 'success'); + await refreshUsers(); + await loadSimulationSettings(); + } catch (error) { setMessage($('#state-message'), error.message, 'error'); } + finally { event.currentTarget.value = ''; } +} + async function uploadCore(event) { event.preventDefault(); const file = $('#core-file').files[0]; @@ -436,5 +519,6 @@ async function uploadCore(event) { finally { button.disabled = false; } } +bootTheme(); if (document.body.dataset.page === 'viewer') bootViewer(); else bootLogin(); \ No newline at end of file diff --git a/web/user.html b/web/user.html index 4753827..cc8c5ad 100644 --- a/web/user.html +++ b/web/user.html @@ -6,6 +6,7 @@ Wolloseum | Player + @@ -14,23 +15,20 @@
W WOLLOSEUM + href="/admin">Admin