use std::time::Duration;
use std::time::Instant;
use flatland_protocol::{Intent, LifeState, NpcView, ResourceNodeView, Seq, Snapshot};
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 hunt_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,
hunt_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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HuntPhase {
Seek,
Fight,
Butcher,
Done,
}
pub struct BotClient<S: PlayConnection> {
config: BotConfig,
session: S,
seq: Seq,
stats: BotStats,
pending: Vec<PendingIntent>,
rng: StdRng,
did_special: bool,
hunt_phase: Option<HuntPhase>,
last_pos: (f32, f32),
last_npcs: Vec<NpcView>,
last_resource_nodes: Vec<ResourceNodeView>,
hunt_target: Option<u64>,
attack_cooldown: u8,
}
impl<S: PlayConnection> BotClient<S> {
pub fn new(config: BotConfig, session: S) -> Self {
let seed = session.entity_id() ^ session.session_id().rotate_left(17);
let hunt_once = config.hunt_once;
Self {
config,
session,
seq: 0,
stats: BotStats::default(),
pending: Vec::new(),
rng: StdRng::seed_from_u64(seed),
did_special: false,
hunt_phase: if hunt_once {
Some(HuntPhase::Seek)
} else {
None
},
last_pos: (0.0, 0.0),
last_npcs: Vec::new(),
last_resource_nodes: Vec::new(),
hunt_target: None,
attack_cooldown: 0,
}
}
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<()> {
if self.config.hunt_once {
if let Some(phase) = self.hunt_phase {
if phase != HuntPhase::Done {
return self.send_hunt_intent().await;
}
}
}
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,
vertical: 0.0,
sprint: false,
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");
self.ingest_snapshot(&snapshot);
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();
if let Some(entity) = delta
.entities
.iter()
.find(|e| e.id == self.session.entity_id())
{
self.last_pos = (
entity.transform.position.x,
entity.transform.position.y,
);
}
if !delta.npcs.is_empty() {
self.last_npcs = delta.npcs;
}
if !delta.resource_nodes.is_empty() {
self.last_resource_nodes = delta.resource_nodes;
}
}
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::ShopOpened(_)
| SessionEvent::UseResult(_)
| SessionEvent::ContentUpdated { .. } => {}
SessionEvent::Disconnected { .. } => {
anyhow::bail!("disconnected");
}
}
Ok(())
}
fn ingest_snapshot(&mut self, snapshot: &Snapshot) {
self.last_npcs = snapshot.npcs.clone();
self.last_resource_nodes = snapshot.resource_nodes.clone();
if let Some(entity) = snapshot
.entities
.iter()
.find(|e| e.id == self.session.entity_id())
{
self.last_pos = (
entity.transform.position.x,
entity.transform.position.y,
);
}
}
fn nearest_wildlife(&self) -> Option<&NpcView> {
let (px, py) = self.last_pos;
self.last_npcs
.iter()
.filter(|n| n.entity_id.is_some())
.filter(|n| n.building_id.is_none())
.filter(|n| n.life_state != Some(LifeState::Dead))
.min_by(|a, b| {
let da = (a.x - px).hypot(a.y - py);
let db = (b.x - px).hypot(b.y - py);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
}
fn nearest_carcass(&self) -> Option<&ResourceNodeView> {
let (px, py) = self.last_pos;
self.last_resource_nodes
.iter()
.filter(|n| n.id.starts_with("carcass-"))
.filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
.min_by(|a, b| {
let da = (a.x - px).hypot(a.y - py);
let db = (b.x - px).hypot(b.y - py);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
}
async fn send_hunt_intent(&mut self) -> anyhow::Result<()> {
let phase = self.hunt_phase.unwrap_or(HuntPhase::Done);
let entity_id = self.session.entity_id();
let (px, py) = self.last_pos;
if let Some(carcass) = self.nearest_carcass().cloned() {
let dist = (carcass.x - px).hypot(carcass.y - py);
if dist <= 2.0 {
self.hunt_phase = Some(HuntPhase::Butcher);
self.seq += 1;
self.session
.submit_intent(Intent::Harvest {
entity_id,
node_id: carcass.id,
seq: self.seq,
})
.await?;
self.stats.intents_sent += 1;
self.hunt_phase = Some(HuntPhase::Done);
return Ok(());
}
}
match phase {
HuntPhase::Seek | HuntPhase::Fight => {
if let Some(prey) = self.nearest_wildlife().cloned() {
let tx = prey.x;
let ty = prey.y;
let prey_entity = prey.entity_id;
let dist = (tx - px).hypot(ty - py);
if dist <= 2.0 {
self.hunt_phase = Some(HuntPhase::Fight);
if self.hunt_target != prey_entity {
self.hunt_target = prey_entity;
self.seq += 1;
if let Some(target_id) = prey_entity {
self.session
.submit_intent(Intent::SetTarget {
entity_id,
target_id,
seq: self.seq,
})
.await?;
self.stats.intents_sent += 1;
}
}
if self.attack_cooldown == 0 {
self.seq += 1;
self.session
.submit_intent(Intent::Attack {
entity_id,
target_id: prey_entity,
weapon_slot: None,
seq: self.seq,
})
.await?;
self.stats.intents_sent += 1;
self.attack_cooldown = 6;
} else {
self.attack_cooldown = self.attack_cooldown.saturating_sub(1);
}
return Ok(());
}
let dx = tx - px;
let dy = ty - py;
let len = (dx * dx + dy * dy).sqrt().max(0.001);
let forward = dy / len;
let strafe = dx / len;
self.seq += 1;
self.session
.submit_intent(Intent::Move {
entity_id,
forward,
strafe,
vertical: 0.0,
sprint: true,
seq: self.seq,
})
.await?;
self.stats.intents_sent += 1;
return Ok(());
}
}
HuntPhase::Butcher | HuntPhase::Done => {}
}
self.send_random_move().await
}
async fn send_random_move(&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,
vertical: 0.0,
sprint: false,
seq,
})
.await?;
self.pending.push(PendingIntent {
sent_at: Instant::now(),
seq,
});
self.stats.intents_sent += 1;
Ok(())
}
}