Skip to main content

flatland_client_lib/
bot.rs

1use std::time::Duration;
2use std::time::Instant;
3
4use flatland_protocol::{Intent, Seq};
5use rand::Rng;
6use rand::SeedableRng;
7use rand::rngs::StdRng;
8use tracing::debug;
9
10use crate::session::{PlayConnection, SessionEvent};
11
12#[derive(Debug, Clone)]
13pub struct BotConfig {
14    pub name: String,
15    pub think_interval: Duration,
16    pub harvest_once: bool,
17    pub say_once: Option<String>,
18}
19
20impl BotConfig {
21    pub fn new(name: impl Into<String>) -> Self {
22        Self {
23            name: name.into(),
24            think_interval: Duration::from_millis(100),
25            harvest_once: false,
26            say_once: None,
27        }
28    }
29}
30
31#[derive(Debug, Default, Clone)]
32pub struct BotStats {
33    pub ticks_received: u64,
34    pub intents_sent: u64,
35    pub intent_acks: u64,
36    pub last_tick: u64,
37    pub last_entity_count: usize,
38    pub intent_latency_p99_ms: f64,
39}
40
41struct PendingIntent {
42    sent_at: Instant,
43    seq: Seq,
44}
45
46pub struct BotClient<S: PlayConnection> {
47    config: BotConfig,
48    session: S,
49    seq: Seq,
50    stats: BotStats,
51    pending: Vec<PendingIntent>,
52    rng: StdRng,
53    did_special: bool,
54}
55
56impl<S: PlayConnection> BotClient<S> {
57    pub fn new(config: BotConfig, session: S) -> Self {
58        let seed = session.entity_id() ^ session.session_id().rotate_left(17);
59        Self {
60            config,
61            session,
62            seq: 0,
63            stats: BotStats::default(),
64            pending: Vec::new(),
65            rng: StdRng::seed_from_u64(seed),
66            did_special: false,
67        }
68    }
69
70    pub fn entity_id(&self) -> u64 {
71        self.session.entity_id()
72    }
73
74    pub fn stats(&self) -> &BotStats {
75        &self.stats
76    }
77
78    pub async fn run_until(&mut self, deadline: Instant) -> anyhow::Result<()> {
79        let mut next_think = tokio::time::Instant::now();
80
81        while Instant::now() < deadline {
82            tokio::select! {
83                _ = tokio::time::sleep_until(next_think) => {
84                    self.send_random_intent().await?;
85                    next_think = tokio::time::Instant::now() + self.config.think_interval;
86                }
87                event = self.session.next_event() => {
88                    match event {
89                        Some(ev) => self.handle_event(ev).await?,
90                        None => break,
91                    }
92                }
93            }
94        }
95
96        self.disconnect();
97        Ok(())
98    }
99
100    pub fn disconnect(&self) {
101        self.session.disconnect();
102    }
103
104    async fn send_random_intent(&mut self) -> anyhow::Result<()> {
105        self.seq += 1;
106        let forward = self.rng.gen_range(-1.0..=1.0);
107        let strafe = self.rng.gen_range(-1.0..=1.0);
108        let seq = self.seq;
109
110        self.session
111            .submit_intent(Intent::Move {
112                entity_id: self.session.entity_id(),
113                forward,
114                strafe,
115                seq,
116            })
117            .await?;
118
119        self.pending.push(PendingIntent {
120            sent_at: Instant::now(),
121            seq,
122        });
123        self.stats.intents_sent += 1;
124        Ok(())
125    }
126
127    async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
128        match event {
129            SessionEvent::Welcome { entity_id, snapshot, .. } => {
130                debug!(bot = %self.config.name, entity_id, "welcome");
131                if !self.did_special {
132                    self.did_special = true;
133                    if self.config.harvest_once {
134                        if let Some(node) = snapshot
135                            .resource_nodes
136                            .iter()
137                            .find(|n| n.state == flatland_protocol::ResourceNodeState::Available)
138                        {
139                            self.seq += 1;
140                            self.session
141                                .submit_intent(Intent::Harvest {
142                                    entity_id: self.session.entity_id(),
143                                    node_id: node.id.clone(),
144                                    seq: self.seq,
145                                })
146                                .await?;
147                            self.stats.intents_sent += 1;
148                        }
149                    }
150                    if let Some(text) = &self.config.say_once {
151                        self.seq += 1;
152                        self.session
153                            .submit_intent(Intent::Say {
154                                entity_id: self.session.entity_id(),
155                                channel: flatland_protocol::ChatChannel::Nearby,
156                                text: text.clone(),
157                                seq: self.seq,
158                            })
159                            .await?;
160                        self.stats.intents_sent += 1;
161                    }
162                }
163            }
164            SessionEvent::Tick(delta) => {
165                self.stats.ticks_received += 1;
166                self.stats.last_tick = delta.tick;
167                self.stats.last_entity_count = delta.entities.len();
168            }
169            SessionEvent::IntentAck { seq, .. } => {
170                self.stats.intent_acks += 1;
171                if let Some(idx) = self.pending.iter().position(|p| p.seq == seq) {
172                    let pending = self.pending.remove(idx);
173                    let ms = pending.sent_at.elapsed().as_secs_f64() * 1000.0;
174                    self.stats.intent_latency_p99_ms =
175                        self.stats.intent_latency_p99_ms.max(ms);
176                }
177            }
178            SessionEvent::Chat(_) | SessionEvent::HarvestResult(_) => {}
179            SessionEvent::CraftResult(_)
180            | SessionEvent::Death(_)
181            | SessionEvent::Interaction(_) => {}
182            SessionEvent::Disconnected { .. } => {
183                anyhow::bail!("disconnected");
184            }
185        }
186        Ok(())
187    }
188}