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}