use std::time::Duration;
use std::time::Instant;
use flatland_protocol::{Intent, Seq};
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::StdRng;
use tracing::debug;
use crate::session::{PlayConnection, SessionEvent};
#[derive(Debug, Clone)]
pub struct BotConfig {
pub name: String,
pub think_interval: Duration,
pub harvest_once: bool,
pub say_once: Option<String>,
}
impl BotConfig {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
think_interval: Duration::from_millis(100),
harvest_once: false,
say_once: None,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct BotStats {
pub ticks_received: u64,
pub intents_sent: u64,
pub intent_acks: u64,
pub last_tick: u64,
pub last_entity_count: usize,
pub intent_latency_p99_ms: f64,
}
struct PendingIntent {
sent_at: Instant,
seq: Seq,
}
pub struct BotClient<S: PlayConnection> {
config: BotConfig,
session: S,
seq: Seq,
stats: BotStats,
pending: Vec<PendingIntent>,
rng: StdRng,
did_special: bool,
}
impl<S: PlayConnection> BotClient<S> {
pub fn new(config: BotConfig, session: S) -> Self {
let seed = session.entity_id() ^ session.session_id().rotate_left(17);
Self {
config,
session,
seq: 0,
stats: BotStats::default(),
pending: Vec::new(),
rng: StdRng::seed_from_u64(seed),
did_special: false,
}
}
pub fn entity_id(&self) -> u64 {
self.session.entity_id()
}
pub fn stats(&self) -> &BotStats {
&self.stats
}
pub async fn run_until(&mut self, deadline: Instant) -> anyhow::Result<()> {
let mut next_think = tokio::time::Instant::now();
while Instant::now() < deadline {
tokio::select! {
_ = tokio::time::sleep_until(next_think) => {
self.send_random_intent().await?;
next_think = tokio::time::Instant::now() + self.config.think_interval;
}
event = self.session.next_event() => {
match event {
Some(ev) => self.handle_event(ev).await?,
None => break,
}
}
}
}
self.disconnect();
Ok(())
}
pub fn disconnect(&self) {
self.session.disconnect();
}
async fn send_random_intent(&mut self) -> anyhow::Result<()> {
self.seq += 1;
let forward = self.rng.gen_range(-1.0..=1.0);
let strafe = self.rng.gen_range(-1.0..=1.0);
let seq = self.seq;
self.session
.submit_intent(Intent::Move {
entity_id: self.session.entity_id(),
forward,
strafe,
seq,
})
.await?;
self.pending.push(PendingIntent {
sent_at: Instant::now(),
seq,
});
self.stats.intents_sent += 1;
Ok(())
}
async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
match event {
SessionEvent::Welcome { entity_id, snapshot, .. } => {
debug!(bot = %self.config.name, entity_id, "welcome");
if !self.did_special {
self.did_special = true;
if self.config.harvest_once {
if let Some(node) = snapshot
.resource_nodes
.iter()
.find(|n| n.state == flatland_protocol::ResourceNodeState::Available)
{
self.seq += 1;
self.session
.submit_intent(Intent::Harvest {
entity_id: self.session.entity_id(),
node_id: node.id.clone(),
seq: self.seq,
})
.await?;
self.stats.intents_sent += 1;
}
}
if let Some(text) = &self.config.say_once {
self.seq += 1;
self.session
.submit_intent(Intent::Say {
entity_id: self.session.entity_id(),
channel: flatland_protocol::ChatChannel::Nearby,
text: text.clone(),
seq: self.seq,
})
.await?;
self.stats.intents_sent += 1;
}
}
}
SessionEvent::Tick(delta) => {
self.stats.ticks_received += 1;
self.stats.last_tick = delta.tick;
self.stats.last_entity_count = delta.entities.len();
}
SessionEvent::IntentAck { seq, .. } => {
self.stats.intent_acks += 1;
if let Some(idx) = self.pending.iter().position(|p| p.seq == seq) {
let pending = self.pending.remove(idx);
let ms = pending.sent_at.elapsed().as_secs_f64() * 1000.0;
self.stats.intent_latency_p99_ms =
self.stats.intent_latency_p99_ms.max(ms);
}
}
SessionEvent::Chat(_) | SessionEvent::HarvestResult(_) => {}
SessionEvent::CraftResult(_)
| SessionEvent::Death(_)
| SessionEvent::Interaction(_) => {}
SessionEvent::Disconnected { .. } => {
anyhow::bail!("disconnected");
}
}
Ok(())
}
}