This commit is contained in:
Steve Biedermann 2026-10-01 21:02:55 +02:00
parent 7b58198d16
commit a18297d1a1
22 changed files with 1409 additions and 241 deletions

18
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

7
agent-api/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "agent-api"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }

38
agent-api/src/lib.rs Normal file
View File

@ -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>;
}

7
core-api/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "core-api"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }

26
core-api/src/lib.rs Normal file
View File

@ -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>;
}

View File

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

View File

@ -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,
}

View File

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

View File

@ -7,6 +7,7 @@ edition = "2021"
crate-type = ["cdylib"]
[dependencies]
agent-api = { path = "../agent-api" }
extism-pdk = "1"
serde = { version = "1", features = ["derive"] }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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">&#9789;</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>

View File

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

View File

@ -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();

View File

@ -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">&#9789;</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>

View File

@ -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">&#9789;</button></nav>
</header>
<main>
<div class="page-head viewer-head">