step
This commit is contained in:
parent
774b812bcd
commit
7b58198d16
|
|
@ -5,6 +5,7 @@ edition = "2024"
|
|||
|
||||
[workspace]
|
||||
members = ["default-core"]
|
||||
exclude = ["example-wasm-agent"]
|
||||
resolver = "3"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
10
README.md
10
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
|
||||
|
|
|
|||
491
src/server.rs
491
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<String>>(3)?,
|
||||
row.get::<_, Option<String>>(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<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 {
|
||||
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<StoredAgent>, Option<StoredCore>), String> {
|
||||
) -> Result<(Vec<StoredAgent>, Option<StoredCore>, 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<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
|
||||
.prepare(
|
||||
"SELECT agents.identity_id, agents.wasm, agents.sha256
|
||||
|
|
@ -435,20 +575,77 @@ fn load_simulation_inputs(
|
|||
.map_err(|error| error.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.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)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
fn activate_queued_agents(state: &AppState) -> Result<usize, String> {
|
||||
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())?;
|
||||
Ok((agents, core))
|
||||
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())?
|
||||
.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())?;
|
||||
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)]
|
||||
|
|
@ -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<String>,
|
||||
queued_agent_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -704,6 +925,36 @@ struct GameStatus {
|
|||
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(
|
||||
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<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
|
||||
.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<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
|
||||
.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<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_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",
|
||||
|
|
|
|||
|
|
@ -89,6 +89,30 @@
|
|||
stream.</p><button id="game-control" class="button" type="button">Pause simulation</button>
|
||||
</div>
|
||||
</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">
|
||||
<div class="panel-head">
|
||||
<h2>Session</h2>
|
||||
|
|
|
|||
49
web/app.js
49
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) {
|
||||
|
|
|
|||
|
|
@ -55,12 +55,12 @@
|
|||
<div class="panel-body stack">
|
||||
<div class="stat-row">
|
||||
<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>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Simulation</div>
|
||||
<div class="stat-value"><span id="game-status-copy">CONNECTING</span></div>
|
||||
<div class="stat-label">Queued module</div>
|
||||
<div id="queued-agent-digest" class="stat-value small">None</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="agent-form" class="stack">
|
||||
|
|
|
|||
Loading…
Reference in New Issue