mod state_machine;
use anyhow::Result;
use battleware_client::Client;
use battleware_types::{
api::{Update, UpdatesFilter, MAX_SUBMISSION_TRANSACTIONS},
execution::{Account, Key, Value},
Query,
};
use commonware_cryptography::{
bls12381::primitives::variant::{MinSig, Variant},
ed25519::{PrivateKey, PublicKey},
PrivateKeyExt, Signer,
};
use commonware_macros::select;
use commonware_runtime::{Clock, Metrics, Spawner};
use commonware_storage::store::operation::Variable;
use futures::future::join_all;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use state_machine::Engine as BotEngine;
use std::{
collections::{hash_map::Entry, HashMap},
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::{Duration, Instant},
};
use tracing::{error, info, warn};
const CONNECTION_STALE_THRESHOLD: Duration = Duration::from_secs(10);
const UPLOADS_OUTSTANDING_WAIT_THRESHOLD: Duration = Duration::from_millis(250);
const STUCK_THRESHOLD: usize = 3;
const STUCK_EXIT_THRESHOLD: usize = 20;
const BATCH_FETCH_SIZE: usize = 50;
const MAX_UPLOADS_OUTSTANDING: usize = 5;
pub const SEED_LENGTH: usize = 32;
pub struct EngineConfig {
pub num_keys: usize,
pub network_identity: <MinSig as Variant>::Public,
pub seed: [u8; SEED_LENGTH],
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Config {
pub num_keys: usize,
pub base_url: String,
pub network_identity: String,
pub log_level: String,
pub seed: String,
pub worker_threads: usize,
}
pub struct Engine<E: Clock + Spawner + Metrics> {
context: E,
config: EngineConfig,
client: Client,
bot: BotEngine,
last_account_batch: Option<Instant>,
}
impl<E: Clock + Spawner + Metrics> Engine<E> {
pub async fn new(context: E, config: EngineConfig, client: Client) -> Result<Self> {
let bot = BotEngine::new(config.network_identity);
Ok(Self {
context,
config,
client,
bot,
last_account_batch: None,
})
}
async fn load_account(
client: &Client,
account: &PublicKey,
) -> Result<Option<(Account, Option<Value>)>> {
let Some(state) = client.query_state(&Key::Account(account.clone())).await? else {
error!("Account {} not found", account);
return Ok(None);
};
let Variable::Update(_, Value::Account(state)) = state.operation else {
panic!("Expected account update");
};
let battle = if let Some(battle) = state.battle {
let Some(battle) = client.query_state(&Key::Battle(battle)).await? else {
return Err(anyhow::anyhow!("Battle not found"));
};
let Variable::Update(_, battle) = battle.operation else {
return Err(anyhow::anyhow!("Expected battle update"));
};
Some(battle)
} else {
None
};
Ok(Some((state, battle)))
}
async fn load_accounts(&mut self, accounts: Vec<PublicKey>) {
for chunk in accounts.chunks(BATCH_FETCH_SIZE) {
let client = &self.client;
let jobs = chunk.iter().map(|public| async move {
Self::load_account(client, public)
.await
.unwrap_or_else(|e| {
warn!(?public, ?e, "Failed to load account");
None
})
});
let results = join_all(jobs).await;
for (public, result) in chunk.iter().cloned().zip(results) {
let Some((state, battle)) = result else {
continue;
};
self.bot.refresh_account(public, state, battle);
}
}
}
pub async fn run(mut self) {
let mut rng = ChaCha20Rng::from_seed(self.config.seed);
let mut stream = self
.client
.connect_updates(UpdatesFilter::All)
.await
.expect("failed to connect to updates");
let mut seed_cache = HashMap::new();
let uploads_outstanding = Arc::new(AtomicUsize::new(0));
loop {
let now = Instant::now();
if self.bot.accounts() < self.config.num_keys {
let should_add = self
.last_account_batch
.is_none_or(|last| now.duration_since(last) >= Duration::from_secs(1));
if should_add {
let private = PrivateKey::from_rng(&mut rng);
let public = private.public_key();
self.bot.add_account(private);
self.load_accounts(vec![public]).await;
info!(
elapsed = ?now.elapsed(),
total = self.bot.accounts(),
"Initialized account",
);
self.last_account_batch = Some(Instant::now());
}
}
let (is_seed, requested, mut txs) = select! {
update = stream.next() => {
let update = update.expect("stream closed").expect("failed to handle update");
let is_seed = matches!(update, Update::Seed(_));
let (requested, txs) = self.bot.apply(update);
(is_seed, requested, txs)
},
_ = self.context.sleep(CONNECTION_STALE_THRESHOLD) => {
warn!("Connection stale");
return;
}
};
if is_seed && requested.is_empty() && !seed_cache.is_empty() {
warn!(size = seed_cache.len(), "Cleared missing seeds");
seed_cache.clear();
}
for index in requested {
if let Entry::Vacant(entry) = seed_cache.entry(index) {
let Ok(Some(seed)) = self.client.query_seed(Query::Index(index)).await else {
warn!("Failed to request seed: {}", index);
continue;
};
entry.insert(seed);
}
let (_, new_txs) = self.bot.apply_seed(seed_cache[&index].clone());
txs.extend(new_txs);
}
loop {
let outstanding = uploads_outstanding.load(Ordering::Relaxed);
if outstanding < MAX_UPLOADS_OUTSTANDING {
break;
}
warn!(outstanding, "Waiting for uploads to be under max");
self.context.sleep(UPLOADS_OUTSTANDING_WAIT_THRESHOLD).await;
}
while !txs.is_empty() {
uploads_outstanding.fetch_add(1, Ordering::Relaxed);
let chunk: Vec<_> = txs
.drain(..MAX_SUBMISSION_TRANSACTIONS.min(txs.len()))
.collect();
self.context.with_label("submit").spawn({
let client = self.client.clone();
let uploads_outstanding = uploads_outstanding.clone();
move |_| async move {
if let Err(e) = client.submit_transactions(chunk).await {
warn!("Failed to submit transaction: {}", e);
}
uploads_outstanding.fetch_sub(1, Ordering::Relaxed);
}
});
}
let stuck = self.bot.stuck(STUCK_THRESHOLD);
assert!(
stuck.len() <= STUCK_EXIT_THRESHOLD,
"Exceeded stuck threshold"
);
if !stuck.is_empty() {
warn!(?stuck, "Refreshing stuck accounts");
self.load_accounts(stuck).await;
}
let (uninitialized, generating, lobby, battle) = self.bot.stats();
info!(uninitialized, generating, lobby, battle, "Stats");
}
}
}