This commit is contained in:
Steve Biedermann 2026-10-01 11:40:30 +02:00
parent 774b812bcd
commit 7b58198d16
6 changed files with 545 additions and 34 deletions

View File

@ -5,6 +5,7 @@ edition = "2024"
[workspace] [workspace]
members = ["default-core"] members = ["default-core"]
exclude = ["example-wasm-agent"]
resolver = "3" resolver = "3"
[dependencies] [dependencies]

View File

@ -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/me` | Session | Read the signed-in identity and agent status |
| `GET` | `/api/users` | Admin session | List identities and agent digests | | `GET` | `/api/users` | Admin session | List identities and agent digests |
| `GET` | `/api/status` | Public | Read the simulation running state | | `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 | | `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/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/delete_user` | Admin session | Delete a player and its uploaded agent |
| `POST` | `/api/start_game` | Admin session | Resume the simulation | | `POST` | `/api/start_game` | Admin session | Resume the simulation |
| `POST` | `/api/stop_game` | Admin session | Pause 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 | | `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 Agent and core WASM uploads are limited to 1 MiB and validated in the
sandboxed runtime before they are persisted. Existing signed request headers sandboxed runtime before they are persisted. Existing signed request headers
remain accepted for compatibility with older clients. 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 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` is uploaded. An uploaded core replaces it and must export an Extism `tick`
function that accepts `CoreTickInput` JSON (`world` and `actions`) and returns function that accepts `CoreTickInput` JSON (`world` and `actions`) and returns

View File

@ -146,7 +146,11 @@ async fn current_identity(
let database = state.database.lock().map_err(ApiError::internal)?; let database = state.database.lock().map_err(ApiError::internal)?;
let identity = database let identity = database
.query_row( .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], [&client_id],
|row| { |row| {
Ok(( Ok((
@ -154,6 +158,7 @@ async fn current_identity(
row.get::<_, bool>(1)?, row.get::<_, bool>(1)?,
row.get::<_, bool>(2)?, row.get::<_, bool>(2)?,
row.get::<_, Option<String>>(3)?, row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
)) ))
}, },
) )
@ -164,6 +169,7 @@ async fn current_identity(
is_admin: identity.1, is_admin: identity.1,
has_agent: identity.2, has_agent: identity.2,
agent_sha256: identity.3, agent_sha256: identity.3,
queued_agent_sha256: identity.4,
})) }))
} }
@ -175,7 +181,11 @@ async fn list_users(
authenticate_session(&state, token, true)?; authenticate_session(&state, token, true)?;
let database = state.database.lock().map_err(ApiError::internal)?; let database = state.database.lock().map_err(ApiError::internal)?;
let mut statement = database 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)?; .map_err(ApiError::internal)?;
let users = statement let users = statement
.query_map([], |row| { .query_map([], |row| {
@ -185,6 +195,7 @@ async fn list_users(
is_admin: row.get(2)?, is_admin: row.get(2)?,
has_agent: row.get(3)?, has_agent: row.get(3)?,
agent_sha256: row.get(4)?, agent_sha256: row.get(4)?,
queued_agent_sha256: row.get(5)?,
}) })
}) })
.map_err(ApiError::internal)? .map_err(ApiError::internal)?
@ -221,6 +232,86 @@ struct StoredCore {
sha256: String, 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<Self> {
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<Self, &'static str> {
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 { struct ActiveAgent {
id: AgentId, id: AgentId,
sha256: String, sha256: String,
@ -378,7 +469,7 @@ fn run_simulation_loop(state: AppState) {
continue; continue;
} }
let (agents, core) = match load_simulation_inputs(&state) { let (agents, core, settings) = match load_simulation_inputs(&state) {
Ok(inputs) => inputs, Ok(inputs) => inputs,
Err(error) => { Err(error) => {
eprintln!("Could not load simulation inputs: {error}"); eprintln!("Could not load simulation inputs: {error}");
@ -386,6 +477,31 @@ fn run_simulation_loop(state: AppState) {
} }
}; };
simulation.select_core(core); 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); simulation.sync_agents(agents);
if simulation.core.is_none() { if simulation.core.is_none() {
continue; continue;
@ -415,8 +531,32 @@ fn run_simulation_loop(state: AppState) {
fn load_simulation_inputs( fn load_simulation_inputs(
state: &AppState, state: &AppState,
) -> Result<(Vec<StoredAgent>, Option<StoredCore>), String> { ) -> Result<(Vec<StoredAgent>, Option<StoredCore>, SimulationSettings), String> {
let database = state.database.lock().map_err(|error| error.to_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<Vec<StoredAgent>, 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<Vec<StoredAgent>, String> {
let mut statement = database let mut statement = database
.prepare( .prepare(
"SELECT agents.identity_id, agents.wasm, agents.sha256 "SELECT agents.identity_id, agents.wasm, agents.sha256
@ -435,20 +575,77 @@ fn load_simulation_inputs(
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let core = database Ok(agents)
.query_row( }
"SELECT wasm, sha256 FROM core_module WHERE id = 1",
[], fn activate_queued_agents(state: &AppState) -> Result<usize, String> {
|row| { let mut database = state.database.lock().map_err(|error| error.to_string())?;
Ok(StoredCore { let transaction = database.transaction().map_err(|error| error.to_string())?;
wasm: row.get(0)?, let queued = {
sha256: row.get(1)?, 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)?,
}) })
}, })
) .map_err(|error| error.to_string())?
.optional() .collect::<Result<Vec<_>, _>>()
.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())?; .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<SimulationSettings, rusqlite::Error> {
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)] #[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/me", get(current_identity))
.route("/api/users", get(list_users)) .route("/api/users", get(list_users))
.route("/api/status", get(game_status)) .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/events", get(websocket_events))
.route("/api/create_user", post(create_user)) .route("/api/create_user", post(create_user))
.route("/api/delete_user", post(delete_user)) .route("/api/delete_user", post(delete_user))
@ -586,6 +787,19 @@ fn initialize_database(
sha256 TEXT NOT NULL, sha256 TEXT NOT NULL,
uploaded_at INTEGER 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 ( CREATE TABLE IF NOT EXISTS core_module (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
wasm BLOB NOT NULL, wasm BLOB NOT NULL,
@ -605,6 +819,12 @@ fn initialize_database(
last_used INTEGER NOT NULL 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( connection.execute(
"INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active) "INSERT INTO identities (id, username, public_key, certificate_pem, is_admin, active)
VALUES (?1, 'admin', ?2, '', 1, 1) VALUES (?1, 'admin', ?2, '', 1, 1)
@ -692,6 +912,7 @@ struct IdentityInfo {
is_admin: bool, is_admin: bool,
has_agent: bool, has_agent: bool,
agent_sha256: Option<String>, agent_sha256: Option<String>,
queued_agent_sha256: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -704,6 +925,36 @@ struct GameStatus {
running: bool, running: bool,
} }
async fn get_simulation_settings(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<SimulationSettings>, 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<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<SimulationSettings>, 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( fn authenticate(
state: &AppState, state: &AppState,
headers: &HeaderMap, headers: &HeaderMap,
@ -1021,7 +1272,7 @@ async fn update_agent(
let database = state.database.lock().map_err(ApiError::internal)?; let database = state.database.lock().map_err(ApiError::internal)?;
database database
.execute( .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, ON CONFLICT(identity_id) DO UPDATE SET wasm = excluded.wasm,
sha256 = excluded.sha256, uploaded_at = excluded.uploaded_at", sha256 = excluded.sha256, uploaded_at = excluded.uploaded_at",
params![client_id, body.as_ref(), sha256, unix_time() as i64], params![client_id, body.as_ref(), sha256, unix_time() as i64],
@ -1029,12 +1280,12 @@ async fn update_agent(
.map_err(ApiError::internal)?; .map_err(ApiError::internal)?;
publish_state_event( publish_state_event(
&state, &state,
"agent_uploaded", "agent_queued",
serde_json::json!({ "bytes": body.len() }), serde_json::json!({ "bytes": body.len(), "sha256": sha256 }),
); );
Ok(Json( 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
AppState, Pki, authenticate, certificate_id, create_user, delete_user, AgentActivationMode, AppState, Pki, SimulationSettings, authenticate, certificate_id,
request_signature_message, start_game, stop_game, unix_time, update_agent, create_user, delete_user, queued_agents_should_activate, request_signature_message,
verify_signature, start_game, stop_game, unix_time, update_agent, verify_signature,
}; };
use axum::Json; use axum::Json;
use axum::body::Bytes; use axum::body::Bytes;
@ -1359,8 +1610,13 @@ mod tests {
} }
fn valid_agent_wasm() -> Vec<u8> { fn valid_agent_wasm() -> Vec<u8> {
let output = br#"{"abi_version":1,"action":0}"#; valid_agent_wasm_action(0)
}
fn valid_agent_wasm_action(action: i32) -> Vec<u8> {
let output = format!(r#"{{"abi_version":1,"action":{action}}}"#);
let stores = output let stores = output
.as_bytes()
.chunks(8) .chunks(8)
.enumerate() .enumerate()
.map(|(index, chunk)| { .map(|(index, chunk)| {
@ -1392,6 +1648,112 @@ mod tests {
wat::parse_str(source).unwrap() 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] #[tokio::test]
async fn challenge_login_consumes_challenge_and_refreshes_hashed_session() { async fn challenge_login_consumes_challenge_and_refreshes_hashed_session() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
@ -1611,7 +1973,46 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(upload_result["bytes"], wasm.len()); assert_eq!(upload_result["bytes"], wasm.len());
assert_eq!(upload_result["queued"], true);
let stored_wasm: Vec<u8> = state let stored_wasm: Vec<u8> = 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<u8> = state
.database .database
.lock() .lock()
.unwrap() .unwrap()
@ -1621,7 +2022,41 @@ mod tests {
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(stored_wasm, wasm); assert_eq!(active_wasm, wasm);
let queued_wasm: Vec<u8> = 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<u8> = 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_body = Bytes::new();
let stop_headers = signed_headers( let stop_headers = signed_headers(
@ -1710,7 +2145,9 @@ mod tests {
for (sequence, expected_event) in [ for (sequence, expected_event) in [
"user_created", "user_created",
"agent_uploaded", "agent_queued",
"agent_queued",
"agent_queued",
"game_state_changed", "game_state_changed",
"game_state_changed", "game_state_changed",
"user_deleted", "user_deleted",

View File

@ -89,6 +89,30 @@
stream.</p><button id="game-control" class="button" type="button">Pause simulation</button> stream.</p><button id="game-control" class="button" type="button">Pause simulation</button>
</div> </div>
</section> </section>
<section class="panel">
<div class="panel-head">
<h2>Agent updates</h2><span class="panel-kicker">SCHEDULE</span>
</div>
<div class="panel-body">
<form id="simulation-settings-form" class="form-grid">
<div class="field"><label for="ticks-per-day">Ticks per day</label><input id="ticks-per-day"
type="number" min="1" max="1000000" required></div>
<div class="field"><label for="days-per-week">Days per week</label><input id="days-per-week"
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="agent-activation">Activate queued agents</label><select
id="agent-activation">
<option value="between_ticks">Between ticks</option>
<option value="between_days">Between days</option>
<option value="between_weeks">Between weeks</option>
<option value="between_months">Between months</option>
</select></div>
<div class="actions"><button class="button" type="submit">Save settings</button></div>
<p id="simulation-settings-message" class="message" role="status"></p>
</form>
</div>
</section>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>Session</h2> <h2>Session</h2>

View File

@ -252,6 +252,7 @@ function bootLogin() {
async function bootUser(identity) { async function bootUser(identity) {
$('#player-id').textContent = identity.client_id; $('#player-id').textContent = identity.client_id;
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded'; $('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
const status = await api('/api/status'); const status = await api('/api/status');
paintGameStatus(status.running); paintGameStatus(status.running);
$('#agent-form').addEventListener('submit', async event => { $('#agent-form').addEventListener('submit', async event => {
@ -263,13 +264,19 @@ async function bootUser(identity) {
setMessage($('#agent-message'), `Uploading ${file.name}...`); setMessage($('#agent-message'), `Uploading ${file.name}...`);
try { try {
const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() }); const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() });
$('#agent-digest').textContent = result.sha256; $('#queued-agent-digest').textContent = result.sha256;
setMessage($('#agent-message'), `Agent accepted | ${result.bytes.toLocaleString()} bytes`, 'success'); setMessage($('#agent-message'), `Agent queued | ${result.bytes.toLocaleString()} bytes`, 'success');
} catch (error) { setMessage($('#agent-message'), error.message, 'error'); } } catch (error) { setMessage($('#agent-message'), error.message, 'error'); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
connectEvents(event => { connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running); 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'; $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation';
$('#user-form').addEventListener('submit', createUser); $('#user-form').addEventListener('submit', createUser);
$('#core-form').addEventListener('submit', uploadCore); $('#core-form').addEventListener('submit', uploadCore);
$('#simulation-settings-form').addEventListener('submit', saveSimulationSettings);
await loadSimulationSettings();
await refreshUsers(); await refreshUsers();
connectEvents(event => { connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') { if (event.event === 'snapshot' || event.event === 'game_state_changed') {
paintGameStatus(event.data.running); paintGameStatus(event.data.running);
$('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation'; $('#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) { async function createUser(event) {
event.preventDefault(); event.preventDefault();
const username = $('#new-username').value.trim(); const username = $('#new-username').value.trim();
@ -358,7 +395,11 @@ async function refreshUsers() {
id.textContent = user.client_id; id.textContent = user.client_id;
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'user-meta'; 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); info.append(name, id, meta);
row.append(info); row.append(info);
if (!user.is_admin) { if (!user.is_admin) {

View File

@ -55,12 +55,12 @@
<div class="panel-body stack"> <div class="panel-body stack">
<div class="stat-row"> <div class="stat-row">
<div class="stat"> <div class="stat">
<div class="stat-label">Current module</div> <div class="stat-label">Active module</div>
<div id="agent-digest" class="stat-value small">Loading...</div> <div id="agent-digest" class="stat-value small">Loading...</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-label">Simulation</div> <div class="stat-label">Queued module</div>
<div class="stat-value"><span id="game-status-copy">CONNECTING</span></div> <div id="queued-agent-digest" class="stat-value small">None</div>
</div> </div>
</div> </div>
<form id="agent-form" class="stack"> <form id="agent-form" class="stack">