Skip to main content

flatland_client_lib/
bot.rs

1use std::time::Duration;
2use std::time::Instant;
3
4use flatland_protocol::{Intent, LifeState, NpcView, ResourceNodeView, Seq, Snapshot};
5use rand::rngs::StdRng;
6use rand::Rng;
7use rand::SeedableRng;
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 hunt_once: bool,
18    pub say_once: Option<String>,
19}
20
21impl BotConfig {
22    pub fn new(name: impl Into<String>) -> Self {
23        Self {
24            name: name.into(),
25            think_interval: Duration::from_millis(100),
26            harvest_once: false,
27            hunt_once: false,
28            say_once: None,
29        }
30    }
31}
32
33#[derive(Debug, Default, Clone)]
34pub struct BotStats {
35    pub ticks_received: u64,
36    pub intents_sent: u64,
37    pub intent_acks: u64,
38    pub last_tick: u64,
39    pub last_entity_count: usize,
40    pub intent_latency_p99_ms: f64,
41}
42
43struct PendingIntent {
44    sent_at: Instant,
45    seq: Seq,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum HuntPhase {
50    Seek,
51    Fight,
52    Butcher,
53    Done,
54}
55
56pub struct BotClient<S: PlayConnection> {
57    config: BotConfig,
58    session: S,
59    seq: Seq,
60    stats: BotStats,
61    pending: Vec<PendingIntent>,
62    rng: StdRng,
63    did_special: bool,
64    hunt_phase: Option<HuntPhase>,
65    last_pos: (f32, f32),
66    last_npcs: Vec<NpcView>,
67    last_resource_nodes: Vec<ResourceNodeView>,
68    hunt_target: Option<u64>,
69    attack_cooldown: u8,
70}
71
72impl<S: PlayConnection> BotClient<S> {
73    pub fn new(config: BotConfig, session: S) -> Self {
74        let seed = session.entity_id() ^ session.session_id().rotate_left(17);
75        let hunt_once = config.hunt_once;
76        Self {
77            config,
78            session,
79            seq: 0,
80            stats: BotStats::default(),
81            pending: Vec::new(),
82            rng: StdRng::seed_from_u64(seed),
83            did_special: false,
84            hunt_phase: if hunt_once {
85                Some(HuntPhase::Seek)
86            } else {
87                None
88            },
89            last_pos: (0.0, 0.0),
90            last_npcs: Vec::new(),
91            last_resource_nodes: Vec::new(),
92            hunt_target: None,
93            attack_cooldown: 0,
94        }
95    }
96
97    pub fn entity_id(&self) -> u64 {
98        self.session.entity_id()
99    }
100
101    pub fn stats(&self) -> &BotStats {
102        &self.stats
103    }
104
105    pub async fn run_until(&mut self, deadline: Instant) -> anyhow::Result<()> {
106        let mut next_think = tokio::time::Instant::now();
107
108        while Instant::now() < deadline {
109            tokio::select! {
110                _ = tokio::time::sleep_until(next_think) => {
111                    self.send_random_intent().await?;
112                    next_think = tokio::time::Instant::now() + self.config.think_interval;
113                }
114                event = self.session.next_event() => {
115                    match event {
116                        Some(ev) => self.handle_event(ev).await?,
117                        None => break,
118                    }
119                }
120            }
121        }
122
123        self.disconnect();
124        Ok(())
125    }
126
127    pub fn disconnect(&self) {
128        self.session.disconnect();
129    }
130
131    async fn send_random_intent(&mut self) -> anyhow::Result<()> {
132        if self.config.hunt_once {
133            if let Some(phase) = self.hunt_phase {
134                if phase != HuntPhase::Done {
135                    return self.send_hunt_intent().await;
136                }
137            }
138        }
139
140        self.seq += 1;
141        let forward = self.rng.gen_range(-1.0..=1.0);
142        let strafe = self.rng.gen_range(-1.0..=1.0);
143        let seq = self.seq;
144
145        self.session
146            .submit_intent(Intent::Move {
147                entity_id: self.session.entity_id(),
148                forward,
149                strafe,
150                vertical: 0.0,
151                sprint: false,
152                sneak: false,
153                seq,
154            })
155            .await?;
156
157        self.pending.push(PendingIntent {
158            sent_at: Instant::now(),
159            seq,
160        });
161        self.stats.intents_sent += 1;
162        Ok(())
163    }
164
165    async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
166        match event {
167            SessionEvent::Welcome {
168                entity_id,
169                snapshot,
170                ..
171            } => {
172                debug!(bot = %self.config.name, entity_id, "welcome");
173                self.ingest_snapshot(&snapshot);
174                if !self.did_special {
175                    self.did_special = true;
176                    if self.config.harvest_once {
177                        if let Some(node) = snapshot
178                            .resource_nodes
179                            .iter()
180                            .find(|n| n.state == flatland_protocol::ResourceNodeState::Available)
181                        {
182                            self.seq += 1;
183                            self.session
184                                .submit_intent(Intent::Harvest {
185                                    entity_id: self.session.entity_id(),
186                                    node_id: node.id.clone(),
187                                    seq: self.seq,
188                                })
189                                .await?;
190                            self.stats.intents_sent += 1;
191                        }
192                    }
193                    if let Some(text) = &self.config.say_once {
194                        self.seq += 1;
195                        self.session
196                            .submit_intent(Intent::Say {
197                                entity_id: self.session.entity_id(),
198                                channel: flatland_protocol::ChatChannel::Nearby,
199                                text: text.clone(),
200                                to_entity: None,
201                                seq: self.seq,
202                            })
203                            .await?;
204                        self.stats.intents_sent += 1;
205                    }
206                }
207            }
208            SessionEvent::Tick(delta) => {
209                self.stats.ticks_received += 1;
210                self.stats.last_tick = delta.tick;
211                self.stats.last_entity_count = delta.entities.len();
212                if let Some(entity) = delta
213                    .entities
214                    .iter()
215                    .find(|e| e.id == self.session.entity_id())
216                {
217                    self.last_pos = (entity.transform.position.x, entity.transform.position.y);
218                }
219                if !delta.npcs.is_empty() {
220                    self.last_npcs = delta.npcs;
221                }
222                if !delta.resource_nodes.is_empty() {
223                    self.last_resource_nodes = delta.resource_nodes;
224                }
225            }
226            SessionEvent::IntentAck { seq, .. } => {
227                self.stats.intent_acks += 1;
228                if let Some(idx) = self.pending.iter().position(|p| p.seq == seq) {
229                    let pending = self.pending.remove(idx);
230                    let ms = pending.sent_at.elapsed().as_secs_f64() * 1000.0;
231                    self.stats.intent_latency_p99_ms = self.stats.intent_latency_p99_ms.max(ms);
232                }
233            }
234            SessionEvent::Chat(_) | SessionEvent::HarvestResult(_) => {}
235            SessionEvent::CraftResult(_)
236            | SessionEvent::Death(_)
237            | SessionEvent::Interaction(_)
238            | SessionEvent::ShopOpened(_)
239            | SessionEvent::BankOpened(_)
240            | SessionEvent::StorageOpened(_)
241            | SessionEvent::MarketOpened(_)
242            | SessionEvent::TradeOpened(_)
243            | SessionEvent::TradeClosed { .. }
244            | SessionEvent::UseResult(_)
245            | SessionEvent::NpcTalkOpened(_)
246            | SessionEvent::NpcTalkPending(_)
247            | SessionEvent::NpcTalkReply(_)
248            | SessionEvent::NpcTalkClosed(_)
249            | SessionEvent::NpcTalkError(_)
250            | SessionEvent::QuestOffer(_)
251            | SessionEvent::QuestAccepted(_)
252            | SessionEvent::QuestWithdrawn(_)
253            | SessionEvent::QuestStepCompleted(_)
254            | SessionEvent::QuestCompleted(_)
255            | SessionEvent::QuestCatalogUpdated(_)
256            | SessionEvent::ContentUpdated { .. } => {}
257            SessionEvent::Disconnected { .. } => {
258                anyhow::bail!("disconnected");
259            }
260        }
261        Ok(())
262    }
263
264    fn ingest_snapshot(&mut self, snapshot: &Snapshot) {
265        self.last_npcs = snapshot.npcs.clone();
266        self.last_resource_nodes = snapshot.resource_nodes.clone();
267        if let Some(entity) = snapshot
268            .entities
269            .iter()
270            .find(|e| e.id == self.session.entity_id())
271        {
272            self.last_pos = (entity.transform.position.x, entity.transform.position.y);
273        }
274    }
275
276    fn nearest_wildlife(&self) -> Option<&NpcView> {
277        let (px, py) = self.last_pos;
278        self.last_npcs
279            .iter()
280            .filter(|n| n.entity_id.is_some())
281            .filter(|n| n.building_id.is_none())
282            .filter(|n| n.life_state != Some(LifeState::Dead))
283            .min_by(|a, b| {
284                let da = (a.x - px).hypot(a.y - py);
285                let db = (b.x - px).hypot(b.y - py);
286                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
287            })
288    }
289
290    fn nearest_carcass(&self) -> Option<&ResourceNodeView> {
291        let (px, py) = self.last_pos;
292        self.last_resource_nodes
293            .iter()
294            .filter(|n| n.id.starts_with("carcass-"))
295            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
296            .min_by(|a, b| {
297                let da = (a.x - px).hypot(a.y - py);
298                let db = (b.x - px).hypot(b.y - py);
299                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
300            })
301    }
302
303    async fn send_hunt_intent(&mut self) -> anyhow::Result<()> {
304        let phase = self.hunt_phase.unwrap_or(HuntPhase::Done);
305        let entity_id = self.session.entity_id();
306        let (px, py) = self.last_pos;
307
308        if let Some(carcass) = self.nearest_carcass().cloned() {
309            let dist = (carcass.x - px).hypot(carcass.y - py);
310            if dist <= 2.0 {
311                self.hunt_phase = Some(HuntPhase::Butcher);
312                self.seq += 1;
313                self.session
314                    .submit_intent(Intent::Harvest {
315                        entity_id,
316                        node_id: carcass.id,
317                        seq: self.seq,
318                    })
319                    .await?;
320                self.stats.intents_sent += 1;
321                self.hunt_phase = Some(HuntPhase::Done);
322                return Ok(());
323            }
324        }
325
326        match phase {
327            HuntPhase::Seek | HuntPhase::Fight => {
328                if let Some(prey) = self.nearest_wildlife().cloned() {
329                    let tx = prey.x;
330                    let ty = prey.y;
331                    let prey_entity = prey.entity_id;
332                    let dist = (tx - px).hypot(ty - py);
333                    if dist <= 2.0 {
334                        self.hunt_phase = Some(HuntPhase::Fight);
335                        if self.hunt_target != prey_entity {
336                            self.hunt_target = prey_entity;
337                            self.seq += 1;
338                            if let Some(target_id) = prey_entity {
339                                self.session
340                                    .submit_intent(Intent::SetTarget {
341                                        entity_id,
342                                        target_id,
343                                        seq: self.seq,
344                                    })
345                                    .await?;
346                                self.stats.intents_sent += 1;
347                            }
348                        }
349                        if self.attack_cooldown == 0 {
350                            self.seq += 1;
351                            self.session
352                                .submit_intent(Intent::Attack {
353                                    entity_id,
354                                    target_id: prey_entity,
355                                    weapon_slot: None,
356                                    seq: self.seq,
357                                })
358                                .await?;
359                            self.stats.intents_sent += 1;
360                            self.attack_cooldown = 6;
361                        } else {
362                            self.attack_cooldown = self.attack_cooldown.saturating_sub(1);
363                        }
364                        return Ok(());
365                    }
366
367                    let dx = tx - px;
368                    let dy = ty - py;
369                    let len = (dx * dx + dy * dy).sqrt().max(0.001);
370                    let forward = dy / len;
371                    let strafe = dx / len;
372                    self.seq += 1;
373                    self.session
374                        .submit_intent(Intent::Move {
375                            entity_id,
376                            forward,
377                            strafe,
378                            vertical: 0.0,
379                            sprint: true,
380                            sneak: false,
381                            seq: self.seq,
382                        })
383                        .await?;
384                    self.stats.intents_sent += 1;
385                    return Ok(());
386                }
387            }
388            HuntPhase::Butcher | HuntPhase::Done => {}
389        }
390
391        self.send_random_move().await
392    }
393
394    async fn send_random_move(&mut self) -> anyhow::Result<()> {
395        self.seq += 1;
396        let forward = self.rng.gen_range(-1.0..=1.0);
397        let strafe = self.rng.gen_range(-1.0..=1.0);
398        let seq = self.seq;
399
400        self.session
401            .submit_intent(Intent::Move {
402                entity_id: self.session.entity_id(),
403                forward,
404                strafe,
405                vertical: 0.0,
406                sprint: false,
407                sneak: false,
408                seq,
409            })
410            .await?;
411
412        self.pending.push(PendingIntent {
413            sent_at: Instant::now(),
414            seq,
415        });
416        self.stats.intents_sent += 1;
417        Ok(())
418    }
419}