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