flatland_client_lib/
bot.rs1use 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}