step
This commit is contained in:
parent
7b58198d16
commit
a18297d1a1
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
README.md
73
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 <token>` 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 <token>` 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
|
||||
|
|
@ -98,17 +101,21 @@ 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/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 and agent activation settings |
|
||||
| `PUT` | `/api/settings` | Admin session | Update simulation calendar and agent activation settings |
|
||||
| `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` | 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/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 |
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "agent-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -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<Action, Self::Error>;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "core-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -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<W, A> {
|
||||
pub world: W,
|
||||
pub actions: Vec<A>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CoreTickOutput<W> {
|
||||
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<Self::World, Self::Action>,
|
||||
) -> Result<CoreTickOutput<Self::World>, Self::Error>;
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<AgentAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CoreTickOutput {
|
||||
pub world: World,
|
||||
}
|
||||
pub type CoreTickInput = core_api::CoreTickInput<World, AgentAction>;
|
||||
pub type CoreTickOutput = core_api::CoreTickOutput<World>;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CoreError {
|
||||
|
|
@ -366,6 +328,22 @@ pub fn run_tick(mut input: CoreTickInput) -> Result<CoreTickOutput, CoreError> {
|
|||
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<Self::World, Self::Action>,
|
||||
) -> Result<core_api::CoreTickOutput<Self::World>, Self::Error> {
|
||||
run_tick(input)
|
||||
}
|
||||
}
|
||||
|
||||
struct SplitMix64 {
|
||||
state: u64,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ edition = "2021"
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
agent-api = { path = "../agent-api" }
|
||||
extism-pdk = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Action, Self::Error> {
|
||||
let open_directions: Vec<Direction> = 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<DecisionInput>) -> FnResult<Json<DecisionOutput>> {
|
||||
let input = input.0;
|
||||
|
||||
// Action codes: 1=North, 2=East, 3=South, 4=West, matching adjacent_terrain order.
|
||||
let open_directions: Vec<i32> = 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
983
src/server.rs
983
src/server.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -112,6 +112,14 @@ impl WasmAgent {
|
|||
}
|
||||
}
|
||||
|
||||
impl agent_api::Agent for WasmAgent {
|
||||
type Error = WasmAgentError;
|
||||
|
||||
fn decide(&mut self, observation: AgentObservation) -> Result<Action, Self::Error> {
|
||||
WasmAgent::decide(self, observation)
|
||||
}
|
||||
}
|
||||
|
||||
fn terrain_code(terrain: ObservedTerrain) -> i32 {
|
||||
terrain as i32
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wolloseum | Administration</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
<script>try { var t = localStorage.getItem('wolloseum.theme'); if (t) document.documentElement.dataset.theme = t; } catch (_) { }</script>
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</head>
|
||||
|
||||
|
|
@ -14,22 +15,19 @@
|
|||
<header class="topbar">
|
||||
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
|
||||
<nav class="nav"><a href="/">Player</a><a href="/viewer">Live viewer</a><a href="/admin"
|
||||
aria-current="page">Admin</a></nav>
|
||||
aria-current="page">Admin</a><button id="theme-toggle" class="icon-button secondary button" type="button"
|
||||
aria-label="Toggle dark mode">☽</button></nav>
|
||||
</header>
|
||||
<main>
|
||||
<section id="login-panel" class="panel login-panel">
|
||||
<div class="accent-band"></div>
|
||||
<div class="panel-body">
|
||||
<p class="eyebrow">Restricted console</p>
|
||||
<h2>Administrator sign in</h2>
|
||||
<p class="login-note">Authenticate by signing a fresh server challenge with the admin Ed25519 private key.</p>
|
||||
<p class="eyebrow">Privileged access</p>
|
||||
<h2>Sign in with your identity bundle</h2>
|
||||
<p class="login-note">Choose an administrator or moderator JSON bundle to sign in.</p>
|
||||
<form id="login-form" class="form-grid">
|
||||
<div class="field"><label for="identity-id">Admin identity ID</label><input id="identity-id"
|
||||
autocomplete="username" spellcheck="false" placeholder="Printed when the server starts" required></div>
|
||||
<div class="field"><label for="private-key-file">Admin private key</label><input id="private-key-file"
|
||||
type="file" accept=".pem,.json,application/json" required><span class="help">Use
|
||||
data/keys/admin-private.pem.</span></div>
|
||||
<div class="actions"><button class="button" type="submit">Open console</button></div>
|
||||
<input id="identity-bundle" class="hidden" type="file" accept=".json,application/json">
|
||||
<div class="actions"><button id="login-button" class="button" type="button">Login</button></div>
|
||||
<p id="login-message" class="message" role="status"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -56,7 +54,7 @@
|
|||
<form id="user-form" class="inline-form">
|
||||
<div class="field"><label for="new-username">Create player</label><input id="new-username"
|
||||
maxlength="32" pattern="[A-Za-z0-9_\-]+" placeholder="username" required></div>
|
||||
<button class="button" type="submit">Create keypair</button>
|
||||
<button class="button mod-control" type="submit">Create keypair</button>
|
||||
</form>
|
||||
<p id="create-user-message" class="message" role="status" style="margin-top:10px"></p>
|
||||
<a id="identity-download" class="button secondary hidden" href="#" download>Download identity bundle</a>
|
||||
|
|
@ -65,7 +63,7 @@
|
|||
<div class="empty">Loading identities...</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<section class="panel admin-only">
|
||||
<div class="panel-head">
|
||||
<h2>Core simulation</h2><span class="panel-kicker">SANDBOX VALIDATED</span>
|
||||
</div>
|
||||
|
|
@ -80,16 +78,23 @@
|
|||
</section>
|
||||
</div>
|
||||
<aside class="side-column">
|
||||
<section class="panel">
|
||||
<section class="panel admin-only">
|
||||
<div class="panel-head">
|
||||
<h2>Simulation</h2><span class="panel-kicker">CONTROL</span>
|
||||
</div>
|
||||
<div class="panel-body stack">
|
||||
<p class="side-note">Changes apply immediately. Current state is also published to the live event
|
||||
stream.</p><button id="game-control" class="button" type="button">Pause simulation</button>
|
||||
<button id="reset-world" class="button danger" type="button">Reset world</button>
|
||||
<div class="actions">
|
||||
<button id="export-state" class="button secondary" type="button">Download state</button>
|
||||
<input id="state-file" class="hidden" type="file" accept=".json,application/json">
|
||||
<button id="import-state" class="button secondary" type="button">Restore state</button>
|
||||
</div>
|
||||
<p id="state-message" class="message" role="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<section class="panel admin-only">
|
||||
<div class="panel-head">
|
||||
<h2>Agent updates</h2><span class="panel-kicker">SCHEDULE</span>
|
||||
</div>
|
||||
|
|
@ -101,6 +106,8 @@
|
|||
type="number" min="1" max="366" required></div>
|
||||
<div class="field"><label for="days-per-month">Days per month</label><input id="days-per-month"
|
||||
type="number" min="1" max="366" required></div>
|
||||
<div class="field"><label for="tick-interval-ms">Sleep between ticks (ms)</label><input
|
||||
id="tick-interval-ms" type="number" min="1" max="60000" required></div>
|
||||
<div class="field"><label for="agent-activation">Activate queued agents</label><select
|
||||
id="agent-activation">
|
||||
<option value="between_ticks">Between ticks</option>
|
||||
|
|
@ -126,7 +133,7 @@
|
|||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer><span>WOLLOSEUM | ADMIN</span><span>Privileged actions require an admin identity</span></footer>
|
||||
<footer><span>WOLLOSEUM | ADMIN</span><span>Moderator user management | Administrator server control</span></footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
78
web/app.css
78
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 {
|
||||
|
|
|
|||
146
web/app.js
146
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);
|
||||
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();
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wolloseum | Player</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
<script>try { var t = localStorage.getItem('wolloseum.theme'); if (t) document.documentElement.dataset.theme = t; } catch (_) { }</script>
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</head>
|
||||
|
||||
|
|
@ -14,23 +15,20 @@
|
|||
<header class="topbar">
|
||||
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
|
||||
<nav class="nav"><a href="/" aria-current="page">Player</a><a href="/viewer">Live viewer</a><a
|
||||
href="/admin">Admin</a></nav>
|
||||
href="/admin">Admin</a><button id="theme-toggle" class="icon-button secondary button" type="button"
|
||||
aria-label="Toggle dark mode">☽</button></nav>
|
||||
</header>
|
||||
<main>
|
||||
<section id="login-panel" class="panel login-panel">
|
||||
<div class="accent-band"></div>
|
||||
<div class="panel-body">
|
||||
<p class="eyebrow">Player access</p>
|
||||
<h2>Sign in with your keypair</h2>
|
||||
<p class="login-note">The server issues a one-time challenge. Your private key signs it locally and never
|
||||
leaves this browser.</p>
|
||||
<p class="eyebrow">Identity access</p>
|
||||
<h2>Sign in with your identity bundle</h2>
|
||||
<p class="login-note">Your JSON bundle signs a one-time challenge locally. Its private key never leaves this
|
||||
browser.</p>
|
||||
<form id="login-form" class="form-grid">
|
||||
<div class="field"><label for="identity-id">Identity ID</label><input id="identity-id"
|
||||
autocomplete="username" spellcheck="false" placeholder="SHA-256 public-key fingerprint" required></div>
|
||||
<div class="field"><label for="private-key-file">Private key or identity bundle</label><input
|
||||
id="private-key-file" type="file" accept=".pem,.json,application/json" required><span
|
||||
class="help">Choose the downloaded identity JSON or an Ed25519 PKCS#8 PEM key.</span></div>
|
||||
<div class="actions"><button class="button" type="submit">Sign in</button></div>
|
||||
<input id="identity-bundle" class="hidden" type="file" accept=".json,application/json">
|
||||
<div class="actions"><button id="login-button" class="button" type="button">Login</button></div>
|
||||
<p id="login-message" class="message" role="status"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wolloseum | Live viewer</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
<script>try { var t = localStorage.getItem('wolloseum.theme'); if (t) document.documentElement.dataset.theme = t; } catch (_) { }</script>
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</head>
|
||||
|
||||
|
|
@ -14,7 +15,8 @@
|
|||
<header class="topbar">
|
||||
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
|
||||
<nav class="nav"><a href="/">Player</a><a href="/viewer" aria-current="page">Live viewer</a><a
|
||||
href="/admin">Admin</a></nav>
|
||||
href="/admin">Admin</a><button id="theme-toggle" class="icon-button secondary button" type="button"
|
||||
aria-label="Toggle dark mode">☽</button></nav>
|
||||
</header>
|
||||
<main>
|
||||
<div class="page-head viewer-head">
|
||||
|
|
|
|||
Loading…
Reference in New Issue