From 7b58198d160b94756d1aa8181117888155afc9fe Mon Sep 17 00:00:00 2001
From: Steve Biedermann
Date: Thu, 1 Oct 2026 11:40:30 +0200
Subject: [PATCH] step
---
Cargo.toml | 1 +
README.md | 10 +-
src/server.rs | 489 ++++++++++++++++++++++++++++++++++++++++++++++---
web/admin.html | 24 +++
web/app.js | 49 ++++-
web/user.html | 6 +-
6 files changed, 545 insertions(+), 34 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index c3b73d3..94a410b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
[workspace]
members = ["default-core"]
+exclude = ["example-wasm-agent"]
resolver = "3"
[dependencies]
diff --git a/README.md b/README.md
index c608858..957a0c2 100644
--- a/README.md
+++ b/README.md
@@ -104,18 +104,26 @@ Revoking an identity by deleting it also removes its sessions.
| `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 | Upload or replace the caller's agent WASM |
+| `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
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.
+
The server uses the native Rust implementation in `default-core/` until a core
is uploaded. An uploaded core replaces it and must export an Extism `tick`
function that accepts `CoreTickInput` JSON (`world` and `actions`) and returns
diff --git a/src/server.rs b/src/server.rs
index a1bb992..90d09aa 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -146,7 +146,11 @@ async fn current_identity(
let database = state.database.lock().map_err(ApiError::internal)?;
let identity = database
.query_row(
- "SELECT identities.username, identities.is_admin, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id) FROM identities WHERE identities.id = ?1",
+ "SELECT identities.username, identities.is_admin,
+ 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)
+ FROM identities WHERE identities.id = ?1",
[&client_id],
|row| {
Ok((
@@ -154,6 +158,7 @@ async fn current_identity(
row.get::<_, bool>(1)?,
row.get::<_, bool>(2)?,
row.get::<_, Option>(3)?,
+ row.get::<_, Option>(4)?,
))
},
)
@@ -164,6 +169,7 @@ async fn current_identity(
is_admin: identity.1,
has_agent: identity.2,
agent_sha256: identity.3,
+ queued_agent_sha256: identity.4,
}))
}
@@ -175,7 +181,11 @@ async fn list_users(
authenticate_session(&state, token, true)?;
let database = state.database.lock().map_err(ApiError::internal)?;
let mut statement = database
- .prepare("SELECT identities.id, identities.username, identities.is_admin, EXISTS(SELECT 1 FROM agents WHERE agents.identity_id = identities.id), (SELECT sha256 FROM agents WHERE agents.identity_id = identities.id) FROM identities ORDER BY identities.is_admin DESC, identities.username COLLATE NOCASE")
+ .prepare("SELECT identities.id, identities.username, identities.is_admin,
+ 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)
+ FROM identities ORDER BY identities.is_admin DESC, identities.username COLLATE NOCASE")
.map_err(ApiError::internal)?;
let users = statement
.query_map([], |row| {
@@ -185,6 +195,7 @@ async fn list_users(
is_admin: row.get(2)?,
has_agent: row.get(3)?,
agent_sha256: row.get(4)?,
+ queued_agent_sha256: row.get(5)?,
})
})
.map_err(ApiError::internal)?
@@ -221,6 +232,86 @@ struct StoredCore {
sha256: String,
}
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+enum AgentActivationMode {
+ BetweenTicks,
+ BetweenDays,
+ BetweenWeeks,
+ BetweenMonths,
+}
+
+impl AgentActivationMode {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::BetweenTicks => "between_ticks",
+ Self::BetweenDays => "between_days",
+ Self::BetweenWeeks => "between_weeks",
+ Self::BetweenMonths => "between_months",
+ }
+ }
+
+ fn parse(value: &str) -> Option {
+ match value {
+ "between_ticks" => Some(Self::BetweenTicks),
+ "between_days" => Some(Self::BetweenDays),
+ "between_weeks" => Some(Self::BetweenWeeks),
+ "between_months" => Some(Self::BetweenMonths),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
+struct SimulationSettings {
+ ticks_per_day: u32,
+ days_per_week: u32,
+ days_per_month: u32,
+ agent_activation: AgentActivationMode,
+}
+
+impl Default for SimulationSettings {
+ fn default() -> Self {
+ Self {
+ ticks_per_day: 100,
+ days_per_week: 7,
+ days_per_month: 30,
+ agent_activation: AgentActivationMode::BetweenTicks,
+ }
+ }
+}
+
+impl SimulationSettings {
+ fn validate(self) -> Result {
+ if !(1..=1_000_000).contains(&self.ticks_per_day) {
+ return Err("ticks_per_day must be between 1 and 1000000");
+ }
+ if !(1..=366).contains(&self.days_per_week) {
+ return Err("days_per_week must be between 1 and 366");
+ }
+ if !(1..=366).contains(&self.days_per_month) {
+ return Err("days_per_month must be between 1 and 366");
+ }
+ Ok(self)
+ }
+}
+
+fn queued_agents_should_activate(tick: u64, settings: SimulationSettings) -> bool {
+ let ticks_per_day = u64::from(settings.ticks_per_day);
+ match settings.agent_activation {
+ AgentActivationMode::BetweenTicks => true,
+ AgentActivationMode::BetweenDays => tick > 0 && tick % ticks_per_day == 0,
+ AgentActivationMode::BetweenWeeks => {
+ let ticks_per_week = ticks_per_day * u64::from(settings.days_per_week);
+ tick > 0 && tick % ticks_per_week == 0
+ }
+ AgentActivationMode::BetweenMonths => {
+ let ticks_per_month = ticks_per_day * u64::from(settings.days_per_month);
+ tick > 0 && tick % ticks_per_month == 0
+ }
+ }
+}
+
struct ActiveAgent {
id: AgentId,
sha256: String,
@@ -378,7 +469,7 @@ fn run_simulation_loop(state: AppState) {
continue;
}
- let (agents, core) = match load_simulation_inputs(&state) {
+ let (agents, core, settings) = match load_simulation_inputs(&state) {
Ok(inputs) => inputs,
Err(error) => {
eprintln!("Could not load simulation inputs: {error}");
@@ -386,6 +477,31 @@ fn run_simulation_loop(state: AppState) {
}
};
simulation.select_core(core);
+ let agents = if queued_agents_should_activate(simulation.world.tick(), settings) {
+ match activate_queued_agents(&state) {
+ Ok(0) => agents,
+ Ok(activated) => {
+ publish_state_event(
+ &state,
+ "agents_activated",
+ serde_json::json!({ "count": activated }),
+ );
+ match load_active_agents(&state) {
+ Ok(agents) => agents,
+ Err(error) => {
+ eprintln!("Could not load activated agents: {error}");
+ continue;
+ }
+ }
+ }
+ Err(error) => {
+ eprintln!("Could not activate queued agents: {error}");
+ continue;
+ }
+ }
+ } else {
+ agents
+ };
simulation.sync_agents(agents);
if simulation.core.is_none() {
continue;
@@ -415,8 +531,32 @@ fn run_simulation_loop(state: AppState) {
fn load_simulation_inputs(
state: &AppState,
-) -> Result<(Vec, Option), String> {
+) -> Result<(Vec, Option, SimulationSettings), String> {
let database = state.database.lock().map_err(|error| error.to_string())?;
+ let agents = load_active_agents_from_database(&database)?;
+ let core = database
+ .query_row(
+ "SELECT wasm, sha256 FROM core_module WHERE id = 1",
+ [],
+ |row| {
+ Ok(StoredCore {
+ wasm: row.get(0)?,
+ sha256: row.get(1)?,
+ })
+ },
+ )
+ .optional()
+ .map_err(|error| error.to_string())?;
+ let settings = read_simulation_settings(&database).map_err(|error| error.to_string())?;
+ Ok((agents, core, settings))
+}
+
+fn load_active_agents(state: &AppState) -> Result, String> {
+ let database = state.database.lock().map_err(|error| error.to_string())?;
+ load_active_agents_from_database(&database)
+}
+
+fn load_active_agents_from_database(database: &Connection) -> Result, String> {
let mut statement = database
.prepare(
"SELECT agents.identity_id, agents.wasm, agents.sha256
@@ -435,20 +575,77 @@ fn load_simulation_inputs(
.map_err(|error| error.to_string())?
.collect::, _>>()
.map_err(|error| error.to_string())?;
- let core = database
- .query_row(
- "SELECT wasm, sha256 FROM core_module WHERE id = 1",
- [],
- |row| {
- Ok(StoredCore {
- wasm: row.get(0)?,
- sha256: row.get(1)?,
+ Ok(agents)
+}
+
+fn activate_queued_agents(state: &AppState) -> Result {
+ let mut database = state.database.lock().map_err(|error| error.to_string())?;
+ let transaction = database.transaction().map_err(|error| error.to_string())?;
+ let queued = {
+ let mut statement = transaction
+ .prepare("SELECT identity_id, wasm, sha256 FROM queued_agents ORDER BY identity_id")
+ .map_err(|error| error.to_string())?;
+ statement
+ .query_map([], |row| {
+ Ok(StoredAgent {
+ client_id: row.get(0)?,
+ wasm: row.get(1)?,
+ sha256: row.get(2)?,
})
- },
- )
- .optional()
+ })
+ .map_err(|error| error.to_string())?
+ .collect::, _>>()
+ .map_err(|error| error.to_string())?
+ };
+ for agent in &queued {
+ transaction
+ .execute(
+ "INSERT INTO agents (identity_id, wasm, sha256, uploaded_at) VALUES (?1, ?2, ?3, ?4)
+ ON CONFLICT(identity_id) DO UPDATE SET wasm = excluded.wasm,
+ sha256 = excluded.sha256, uploaded_at = excluded.uploaded_at",
+ params![agent.client_id, agent.wasm, agent.sha256, unix_time() as i64],
+ )
+ .map_err(|error| error.to_string())?;
+ }
+ transaction
+ .execute("DELETE FROM queued_agents", [])
.map_err(|error| error.to_string())?;
- Ok((agents, core))
+ transaction.commit().map_err(|error| error.to_string())?;
+ Ok(queued.len())
+}
+
+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
+ FROM simulation_settings WHERE id = 1",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
+ )?;
+ Ok(SimulationSettings {
+ ticks_per_day,
+ days_per_week,
+ days_per_month,
+ agent_activation: AgentActivationMode::parse(&activation)
+ .unwrap_or(AgentActivationMode::BetweenTicks),
+ })
+}
+
+fn save_simulation_settings(
+ database: &Connection,
+ settings: SimulationSettings,
+) -> 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",
+ params![
+ settings.ticks_per_day,
+ settings.days_per_week,
+ settings.days_per_month,
+ settings.agent_activation.as_str(),
+ ],
+ )?;
+ Ok(())
}
#[derive(Clone, Debug, Serialize)]
@@ -537,6 +734,10 @@ pub async fn run(data_directory: PathBuf, address: SocketAddr) -> Result<(), Box
.route("/api/me", get(current_identity))
.route("/api/users", get(list_users))
.route("/api/status", get(game_status))
+ .route(
+ "/api/settings",
+ get(get_simulation_settings).put(update_simulation_settings),
+ )
.route("/api/events", get(websocket_events))
.route("/api/create_user", post(create_user))
.route("/api/delete_user", post(delete_user))
@@ -586,6 +787,19 @@ fn initialize_database(
sha256 TEXT NOT NULL,
uploaded_at INTEGER NOT NULL
);
+ CREATE TABLE IF NOT EXISTS queued_agents (
+ identity_id TEXT PRIMARY KEY REFERENCES identities(id) ON DELETE CASCADE,
+ wasm BLOB NOT NULL,
+ sha256 TEXT NOT NULL,
+ uploaded_at INTEGER NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS 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
+ );
CREATE TABLE IF NOT EXISTS core_module (
id INTEGER PRIMARY KEY CHECK (id = 1),
wasm BLOB NOT NULL,
@@ -605,6 +819,12 @@ fn initialize_database(
last_used INTEGER NOT NULL
);",
)?;
+ 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')",
+ [],
+ )?;
connection.execute(
"INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active)
VALUES (?1, 'admin', ?2, '', 1, 1)
@@ -692,6 +912,7 @@ struct IdentityInfo {
is_admin: bool,
has_agent: bool,
agent_sha256: Option,
+ queued_agent_sha256: Option,
}
#[derive(Deserialize)]
@@ -704,6 +925,36 @@ struct GameStatus {
running: bool,
}
+async fn get_simulation_settings(
+ State(state): State,
+ headers: HeaderMap,
+) -> Result, ApiError> {
+ authenticate(&state, &headers, &Method::GET, "/api/settings", b"", true)?;
+ let database = state.database.lock().map_err(ApiError::internal)?;
+ Ok(Json(
+ read_simulation_settings(&database).map_err(ApiError::internal)?,
+ ))
+}
+
+async fn update_simulation_settings(
+ State(state): State,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Result, ApiError> {
+ authenticate(&state, &headers, &Method::PUT, "/api/settings", &body, true)?;
+ let settings: SimulationSettings =
+ serde_json::from_slice(&body).map_err(|error| ApiError::bad_request(error.to_string()))?;
+ 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)?;
+ publish_state_event(
+ &state,
+ "simulation_settings_updated",
+ serde_json::json!(settings),
+ );
+ Ok(Json(settings))
+}
+
fn authenticate(
state: &AppState,
headers: &HeaderMap,
@@ -1021,7 +1272,7 @@ async fn update_agent(
let database = state.database.lock().map_err(ApiError::internal)?;
database
.execute(
- "INSERT INTO agents (identity_id, wasm, sha256, uploaded_at) VALUES (?1, ?2, ?3, ?4)
+ "INSERT INTO queued_agents (identity_id, wasm, sha256, uploaded_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(identity_id) DO UPDATE SET wasm = excluded.wasm,
sha256 = excluded.sha256, uploaded_at = excluded.uploaded_at",
params![client_id, body.as_ref(), sha256, unix_time() as i64],
@@ -1029,12 +1280,12 @@ async fn update_agent(
.map_err(ApiError::internal)?;
publish_state_event(
&state,
- "agent_uploaded",
- serde_json::json!({ "bytes": body.len() }),
+ "agent_queued",
+ serde_json::json!({ "bytes": body.len(), "sha256": sha256 }),
);
Ok(Json(
- serde_json::json!({ "sha256": sha256, "bytes": body.len() }),
+ serde_json::json!({ "sha256": sha256, "bytes": body.len(), "queued": true }),
))
}
@@ -1294,9 +1545,9 @@ fn verify_signature(public_key: &[u8], message: &[u8], signature: &[u8]) -> bool
#[cfg(test)]
mod tests {
use super::{
- AppState, Pki, authenticate, certificate_id, create_user, delete_user,
- request_signature_message, start_game, stop_game, unix_time, update_agent,
- verify_signature,
+ AgentActivationMode, AppState, Pki, SimulationSettings, authenticate, certificate_id,
+ create_user, delete_user, queued_agents_should_activate, request_signature_message,
+ start_game, stop_game, unix_time, update_agent, verify_signature,
};
use axum::Json;
use axum::body::Bytes;
@@ -1359,8 +1610,13 @@ mod tests {
}
fn valid_agent_wasm() -> Vec {
- let output = br#"{"abi_version":1,"action":0}"#;
+ valid_agent_wasm_action(0)
+ }
+
+ fn valid_agent_wasm_action(action: i32) -> Vec {
+ let output = format!(r#"{{"abi_version":1,"action":{action}}}"#);
let stores = output
+ .as_bytes()
.chunks(8)
.enumerate()
.map(|(index, chunk)| {
@@ -1392,6 +1648,112 @@ mod tests {
wat::parse_str(source).unwrap()
}
+ #[test]
+ fn queued_agent_activation_uses_configured_calendar_boundaries() {
+ let settings = SimulationSettings {
+ ticks_per_day: 100,
+ days_per_week: 2,
+ days_per_month: 3,
+ agent_activation: AgentActivationMode::BetweenTicks,
+ };
+ assert!(queued_agents_should_activate(0, settings));
+
+ let daily = SimulationSettings {
+ agent_activation: AgentActivationMode::BetweenDays,
+ ..settings
+ };
+ assert!(!queued_agents_should_activate(99, daily));
+ assert!(queued_agents_should_activate(100, daily));
+
+ let weekly = SimulationSettings {
+ agent_activation: AgentActivationMode::BetweenWeeks,
+ ..settings
+ };
+ assert!(!queued_agents_should_activate(100, weekly));
+ assert!(queued_agents_should_activate(200, weekly));
+
+ let monthly = SimulationSettings {
+ agent_activation: AgentActivationMode::BetweenMonths,
+ ..settings
+ };
+ assert!(!queued_agents_should_activate(200, monthly));
+ assert!(queued_agents_should_activate(300, monthly));
+ }
+
+ #[test]
+ fn simulation_settings_reject_zero_calendar_lengths() {
+ assert!(
+ SimulationSettings {
+ ticks_per_day: 0,
+ ..SimulationSettings::default()
+ }
+ .validate()
+ .is_err()
+ );
+ assert!(
+ SimulationSettings {
+ days_per_week: 0,
+ ..SimulationSettings::default()
+ }
+ .validate()
+ .is_err()
+ );
+ assert!(
+ SimulationSettings {
+ days_per_month: 0,
+ ..SimulationSettings::default()
+ }
+ .validate()
+ .is_err()
+ );
+ }
+
+ #[tokio::test]
+ async fn admin_can_read_and_update_simulation_settings() {
+ let directory = tempfile::tempdir().unwrap();
+ let (state, admin_id, admin_key) = test_state(directory.path());
+ let read_headers = signed_headers(
+ &admin_id,
+ &admin_key,
+ &Method::GET,
+ "/api/settings",
+ b"",
+ "admin-settings-read",
+ );
+ let Json(defaults) = super::get_simulation_settings(State(state.clone()), read_headers)
+ .await
+ .unwrap();
+ assert_eq!(defaults.ticks_per_day, 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,
+ "agent_activation": "between_weeks",
+ }))
+ .unwrap();
+ let update_headers = signed_headers(
+ &admin_id,
+ &admin_key,
+ &Method::PUT,
+ "/api/settings",
+ &body,
+ "admin-settings-update",
+ );
+ let Json(updated) = super::update_simulation_settings(
+ State(state.clone()),
+ update_headers,
+ Bytes::from(body),
+ )
+ .await
+ .unwrap();
+ assert_eq!(updated.ticks_per_day, 240);
+ assert_eq!(updated.days_per_week, 6);
+ assert_eq!(updated.days_per_month, 28);
+ assert_eq!(updated.agent_activation, AgentActivationMode::BetweenWeeks);
+ }
+
#[tokio::test]
async fn challenge_login_consumes_challenge_and_refreshes_hashed_session() {
let directory = tempfile::tempdir().unwrap();
@@ -1611,7 +1973,46 @@ mod tests {
.await
.unwrap();
assert_eq!(upload_result["bytes"], wasm.len());
+ assert_eq!(upload_result["queued"], true);
let stored_wasm: Vec = state
+ .database
+ .lock()
+ .unwrap()
+ .query_row(
+ "SELECT wasm FROM queued_agents WHERE identity_id = ?1",
+ [&identity.client_id],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(stored_wasm, wasm);
+ let active_count: i64 = state
+ .database
+ .lock()
+ .unwrap()
+ .query_row(
+ "SELECT COUNT(*) FROM agents WHERE identity_id = ?1",
+ [&identity.client_id],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(active_count, 0);
+ assert_eq!(super::activate_queued_agents(&state).unwrap(), 1);
+
+ for (action, nonce) in [(1, "player-upload-3"), (2, "player-upload-4")] {
+ let replacement = valid_agent_wasm_action(action);
+ let headers = signed_headers(
+ &identity.client_id,
+ &user_key,
+ &Method::POST,
+ "/api/update_agent",
+ &replacement,
+ nonce,
+ );
+ let Json(_) = update_agent(State(state.clone()), headers, Bytes::from(replacement))
+ .await
+ .unwrap();
+ }
+ let active_wasm: Vec = state
.database
.lock()
.unwrap()
@@ -1621,7 +2022,41 @@ mod tests {
|row| row.get(0),
)
.unwrap();
- assert_eq!(stored_wasm, wasm);
+ assert_eq!(active_wasm, wasm);
+ let queued_wasm: Vec = state
+ .database
+ .lock()
+ .unwrap()
+ .query_row(
+ "SELECT wasm FROM queued_agents WHERE identity_id = ?1",
+ [&identity.client_id],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(queued_wasm, valid_agent_wasm_action(2));
+ assert_eq!(super::activate_queued_agents(&state).unwrap(), 1);
+ let active_wasm: Vec = state
+ .database
+ .lock()
+ .unwrap()
+ .query_row(
+ "SELECT wasm FROM agents WHERE identity_id = ?1",
+ [&identity.client_id],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(active_wasm, valid_agent_wasm_action(2));
+ let queued_count: i64 = state
+ .database
+ .lock()
+ .unwrap()
+ .query_row(
+ "SELECT COUNT(*) FROM queued_agents WHERE identity_id = ?1",
+ [&identity.client_id],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(queued_count, 0);
let stop_body = Bytes::new();
let stop_headers = signed_headers(
@@ -1710,7 +2145,9 @@ mod tests {
for (sequence, expected_event) in [
"user_created",
- "agent_uploaded",
+ "agent_queued",
+ "agent_queued",
+ "agent_queued",
"game_state_changed",
"game_state_changed",
"user_deleted",
diff --git a/web/admin.html b/web/admin.html
index 4d14cd5..0103ae1 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -89,6 +89,30 @@
stream.
+
+
+
Agent updates
SCHEDULE
+
+
+
Session
diff --git a/web/app.js b/web/app.js
index 23eea3f..adc49aa 100644
--- a/web/app.js
+++ b/web/app.js
@@ -252,6 +252,7 @@ function bootLogin() {
async function bootUser(identity) {
$('#player-id').textContent = identity.client_id;
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
+ $('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
const status = await api('/api/status');
paintGameStatus(status.running);
$('#agent-form').addEventListener('submit', async event => {
@@ -263,13 +264,19 @@ async function bootUser(identity) {
setMessage($('#agent-message'), `Uploading ${file.name}...`);
try {
const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() });
- $('#agent-digest').textContent = result.sha256;
- setMessage($('#agent-message'), `Agent accepted | ${result.bytes.toLocaleString()} bytes`, 'success');
+ $('#queued-agent-digest').textContent = result.sha256;
+ setMessage($('#agent-message'), `Agent queued | ${result.bytes.toLocaleString()} bytes`, 'success');
} catch (error) { setMessage($('#agent-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running);
+ if (event.event === 'agents_activated') {
+ api('/api/me').then(identity => {
+ $('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
+ $('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
+ }).catch(error => setMessage($('#agent-message'), error.message, 'error'));
+ }
});
}
@@ -300,16 +307,46 @@ async function bootAdmin() {
$('#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();
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 (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'agent_uploaded') refreshUsers();
+ if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers();
});
}
+async function loadSimulationSettings() {
+ try {
+ const settings = await api('/api/settings');
+ $('#ticks-per-day').value = settings.ticks_per_day;
+ $('#days-per-week').value = settings.days_per_week;
+ $('#days-per-month').value = settings.days_per_month;
+ $('#agent-activation').value = settings.agent_activation;
+ } catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
+}
+
+async function saveSimulationSettings(event) {
+ event.preventDefault();
+ const form = event.currentTarget;
+ const button = $('button[type="submit"]', form);
+ button.disabled = true;
+ const settings = {
+ ticks_per_day: Number($('#ticks-per-day').value),
+ days_per_week: Number($('#days-per-week').value),
+ days_per_month: Number($('#days-per-month').value),
+ agent_activation: $('#agent-activation').value,
+ };
+ try {
+ await api('/api/settings', { method: 'PUT', body: JSON.stringify(settings) });
+ setMessage($('#simulation-settings-message'), 'Settings saved.', 'success');
+ } catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
+ finally { button.disabled = false; }
+}
+
async function createUser(event) {
event.preventDefault();
const username = $('#new-username').value.trim();
@@ -358,7 +395,11 @@ async function refreshUsers() {
id.textContent = user.client_id;
const meta = document.createElement('div');
meta.className = 'user-meta';
- meta.textContent = user.has_agent ? `Agent | ${user.agent_sha256 || 'uploaded'}` : 'No agent';
+ const agentStatus = [
+ user.has_agent ? `Active | ${user.agent_sha256 || 'uploaded'}` : 'No active agent',
+ user.queued_agent_sha256 ? `Queued | ${user.queued_agent_sha256}` : '',
+ ].filter(Boolean);
+ meta.textContent = agentStatus.join(' | ');
info.append(name, id, meta);
row.append(info);
if (!user.is_admin) {
diff --git a/web/user.html b/web/user.html
index f781b3f..4753827 100644
--- a/web/user.html
+++ b/web/user.html
@@ -55,12 +55,12 @@
-
Current module
+
Active module
Loading...
-
Simulation
-
CONNECTING
+
Queued module
+
None