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}
17
18impl BotConfig {
19    pub fn new(name: impl Into<String>) -> Self {
20        Self {
21            name: name.into(),
22            think_interval: Duration::from_millis(100),
23        }
24    }
25}
26
27#[derive(Debug, Default, Clone)]
28pub struct BotStats {
29    pub ticks_received: u64,
30    pub intents_sent: u64,
31    pub intent_acks: u64,
32    pub last_tick: u64,
33    pub last_entity_count: usize,
34    pub intent_latency_p99_ms: f64,
35}
36
37struct PendingIntent {
38    sent_at: Instant,
39    seq: Seq,
40}
41
42pub struct BotClient<S: PlayConnection> {
43    config: BotConfig,
44    session: S,
45    seq: Seq,
46    stats: BotStats,
47    pending: Vec<PendingIntent>,
48    rng: StdRng,
49}
50
51impl<S: PlayConnection> BotClient<S> {
52    pub fn new(config: BotConfig, session: S) -> Self {
53        let seed = session.entity_id() ^ session.session_id().rotate_left(17);
54        Self {
55            config,
56            session,
57            seq: 0,
58            stats: BotStats::default(),
59            pending: Vec::new(),
60            rng: StdRng::seed_from_u64(seed),
61        }
62    }
63
64    pub fn entity_id(&self) -> u64 {
65        self.session.entity_id()
66    }
67
68    pub fn stats(&self) -> &BotStats {
69        &self.stats
70    }
71
72    pub async fn run_until(&mut self, deadline: Instant) -> anyhow::Result<()> {
73        let mut next_think = tokio::time::Instant::now();
74
75        while Instant::now() < deadline {
76            tokio::select! {
77                _ = tokio::time::sleep_until(next_think) => {
78                    self.send_random_intent().await?;
79                    next_think = tokio::time::Instant::now() + self.config.think_interval;
80                }
81                event = self.session.next_event() => {
82                    match event {
83                        Some(ev) => self.handle_event(ev).await?,
84                        None => break,
85                    }
86                }
87            }
88        }
89
90        self.disconnect();
91        Ok(())
92    }
93
94    pub fn disconnect(&self) {
95        self.session.disconnect();
96    }
97
98    async fn send_random_intent(&mut self) -> anyhow::Result<()> {
99        self.seq += 1;
100        let forward = self.rng.gen_range(-1.0..=1.0);
101        let strafe = self.rng.gen_range(-1.0..=1.0);
102        let seq = self.seq;
103
104        self.session
105            .submit_intent(Intent::Move {
106                entity_id: self.session.entity_id(),
107                forward,
108                strafe,
109                seq,
110            })
111            .await?;
112
113        self.pending.push(PendingIntent {
114            sent_at: Instant::now(),
115            seq,
116        });
117        self.stats.intents_sent += 1;
118        Ok(())
119    }
120
121    async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
122        match event {
123            SessionEvent::Welcome { entity_id, .. } => {
124                debug!(bot = %self.config.name, entity_id, "welcome");
125            }
126            SessionEvent::Tick(delta) => {
127                self.stats.ticks_received += 1;
128                self.stats.last_tick = delta.tick;
129                self.stats.last_entity_count = delta.entities.len();
130            }
131            SessionEvent::IntentAck { seq, .. } => {
132                self.stats.intent_acks += 1;
133                if let Some(idx) = self.pending.iter().position(|p| p.seq == seq) {
134                    let pending = self.pending.remove(idx);
135                    let ms = pending.sent_at.elapsed().as_secs_f64() * 1000.0;
136                    self.stats.intent_latency_p99_ms =
137                        self.stats.intent_latency_p99_ms.max(ms);
138                }
139            }
140            SessionEvent::Disconnected => {
141                anyhow::bail!("disconnected");
142            }
143        }
144        Ok(())
145    }
146}