step
This commit is contained in:
parent
774b812bcd
commit
7b58198d16
|
|
@ -5,6 +5,7 @@ edition = "2024"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["default-core"]
|
members = ["default-core"]
|
||||||
|
exclude = ["example-wasm-agent"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[dependencies]
|
[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/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
|
||||||
|
|
|
||||||
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 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")
|
||||||
},
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(|error| error.to_string())?;
|
.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)]
|
#[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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
49
web/app.js
49
web/app.js
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue