flatland-client-lib 0.2.3

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
use std::time::Duration;
use std::time::Instant;

use flatland_protocol::{Intent, LifeState, NpcView, ResourceNodeView, Seq, Snapshot};
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::StdRng;
use tracing::debug;

use crate::session::{PlayConnection, SessionEvent};

#[derive(Debug, Clone)]
pub struct BotConfig {
    pub name: String,
    pub think_interval: Duration,
    pub harvest_once: bool,
    pub hunt_once: bool,
    pub say_once: Option<String>,
}

impl BotConfig {
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            think_interval: Duration::from_millis(100),
            harvest_once: false,
            hunt_once: false,
            say_once: None,
        }
    }
}

#[derive(Debug, Default, Clone)]
pub struct BotStats {
    pub ticks_received: u64,
    pub intents_sent: u64,
    pub intent_acks: u64,
    pub last_tick: u64,
    pub last_entity_count: usize,
    pub intent_latency_p99_ms: f64,
}

struct PendingIntent {
    sent_at: Instant,
    seq: Seq,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HuntPhase {
    Seek,
    Fight,
    Butcher,
    Done,
}

pub struct BotClient<S: PlayConnection> {
    config: BotConfig,
    session: S,
    seq: Seq,
    stats: BotStats,
    pending: Vec<PendingIntent>,
    rng: StdRng,
    did_special: bool,
    hunt_phase: Option<HuntPhase>,
    last_pos: (f32, f32),
    last_npcs: Vec<NpcView>,
    last_resource_nodes: Vec<ResourceNodeView>,
    hunt_target: Option<u64>,
    attack_cooldown: u8,
}

impl<S: PlayConnection> BotClient<S> {
    pub fn new(config: BotConfig, session: S) -> Self {
        let seed = session.entity_id() ^ session.session_id().rotate_left(17);
        let hunt_once = config.hunt_once;
        Self {
            config,
            session,
            seq: 0,
            stats: BotStats::default(),
            pending: Vec::new(),
            rng: StdRng::seed_from_u64(seed),
            did_special: false,
            hunt_phase: if hunt_once {
                Some(HuntPhase::Seek)
            } else {
                None
            },
            last_pos: (0.0, 0.0),
            last_npcs: Vec::new(),
            last_resource_nodes: Vec::new(),
            hunt_target: None,
            attack_cooldown: 0,
        }
    }

    pub fn entity_id(&self) -> u64 {
        self.session.entity_id()
    }

    pub fn stats(&self) -> &BotStats {
        &self.stats
    }

    pub async fn run_until(&mut self, deadline: Instant) -> anyhow::Result<()> {
        let mut next_think = tokio::time::Instant::now();

        while Instant::now() < deadline {
            tokio::select! {
                _ = tokio::time::sleep_until(next_think) => {
                    self.send_random_intent().await?;
                    next_think = tokio::time::Instant::now() + self.config.think_interval;
                }
                event = self.session.next_event() => {
                    match event {
                        Some(ev) => self.handle_event(ev).await?,
                        None => break,
                    }
                }
            }
        }

        self.disconnect();
        Ok(())
    }

    pub fn disconnect(&self) {
        self.session.disconnect();
    }

    async fn send_random_intent(&mut self) -> anyhow::Result<()> {
        if self.config.hunt_once {
            if let Some(phase) = self.hunt_phase {
                if phase != HuntPhase::Done {
                    return self.send_hunt_intent().await;
                }
            }
        }

        self.seq += 1;
        let forward = self.rng.gen_range(-1.0..=1.0);
        let strafe = self.rng.gen_range(-1.0..=1.0);
        let seq = self.seq;

        self.session
            .submit_intent(Intent::Move {
                entity_id: self.session.entity_id(),
                forward,
                strafe,
                vertical: 0.0,
                sprint: false,
                seq,
            })
            .await?;

        self.pending.push(PendingIntent {
            sent_at: Instant::now(),
            seq,
        });
        self.stats.intents_sent += 1;
        Ok(())
    }

    async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
        match event {
            SessionEvent::Welcome { entity_id, snapshot, .. } => {
                debug!(bot = %self.config.name, entity_id, "welcome");
                self.ingest_snapshot(&snapshot);
                if !self.did_special {
                    self.did_special = true;
                    if self.config.harvest_once {
                        if let Some(node) = snapshot
                            .resource_nodes
                            .iter()
                            .find(|n| n.state == flatland_protocol::ResourceNodeState::Available)
                        {
                            self.seq += 1;
                            self.session
                                .submit_intent(Intent::Harvest {
                                    entity_id: self.session.entity_id(),
                                    node_id: node.id.clone(),
                                    seq: self.seq,
                                })
                                .await?;
                            self.stats.intents_sent += 1;
                        }
                    }
                    if let Some(text) = &self.config.say_once {
                        self.seq += 1;
                        self.session
                            .submit_intent(Intent::Say {
                                entity_id: self.session.entity_id(),
                                channel: flatland_protocol::ChatChannel::Nearby,
                                text: text.clone(),
                                seq: self.seq,
                            })
                            .await?;
                        self.stats.intents_sent += 1;
                    }
                }
            }
            SessionEvent::Tick(delta) => {
                self.stats.ticks_received += 1;
                self.stats.last_tick = delta.tick;
                self.stats.last_entity_count = delta.entities.len();
                if let Some(entity) = delta
                    .entities
                    .iter()
                    .find(|e| e.id == self.session.entity_id())
                {
                    self.last_pos = (
                        entity.transform.position.x,
                        entity.transform.position.y,
                    );
                }
                if !delta.npcs.is_empty() {
                    self.last_npcs = delta.npcs;
                }
                if !delta.resource_nodes.is_empty() {
                    self.last_resource_nodes = delta.resource_nodes;
                }
            }
            SessionEvent::IntentAck { seq, .. } => {
                self.stats.intent_acks += 1;
                if let Some(idx) = self.pending.iter().position(|p| p.seq == seq) {
                    let pending = self.pending.remove(idx);
                    let ms = pending.sent_at.elapsed().as_secs_f64() * 1000.0;
                    self.stats.intent_latency_p99_ms =
                        self.stats.intent_latency_p99_ms.max(ms);
                }
            }
            SessionEvent::Chat(_) | SessionEvent::HarvestResult(_) => {}
            SessionEvent::CraftResult(_)
            | SessionEvent::Death(_)
            | SessionEvent::Interaction(_)
            | SessionEvent::ShopOpened(_)
            | SessionEvent::UseResult(_)
            | SessionEvent::ContentUpdated { .. } => {}
            SessionEvent::Disconnected { .. } => {
                anyhow::bail!("disconnected");
            }
        }
        Ok(())
    }

    fn ingest_snapshot(&mut self, snapshot: &Snapshot) {
        self.last_npcs = snapshot.npcs.clone();
        self.last_resource_nodes = snapshot.resource_nodes.clone();
        if let Some(entity) = snapshot
            .entities
            .iter()
            .find(|e| e.id == self.session.entity_id())
        {
            self.last_pos = (
                entity.transform.position.x,
                entity.transform.position.y,
            );
        }
    }

    fn nearest_wildlife(&self) -> Option<&NpcView> {
        let (px, py) = self.last_pos;
        self.last_npcs
            .iter()
            .filter(|n| n.entity_id.is_some())
            .filter(|n| n.building_id.is_none())
            .filter(|n| n.life_state != Some(LifeState::Dead))
            .min_by(|a, b| {
                let da = (a.x - px).hypot(a.y - py);
                let db = (b.x - px).hypot(b.y - py);
                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
            })
    }

    fn nearest_carcass(&self) -> Option<&ResourceNodeView> {
        let (px, py) = self.last_pos;
        self.last_resource_nodes
            .iter()
            .filter(|n| n.id.starts_with("carcass-"))
            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
            .min_by(|a, b| {
                let da = (a.x - px).hypot(a.y - py);
                let db = (b.x - px).hypot(b.y - py);
                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
            })
    }

    async fn send_hunt_intent(&mut self) -> anyhow::Result<()> {
        let phase = self.hunt_phase.unwrap_or(HuntPhase::Done);
        let entity_id = self.session.entity_id();
        let (px, py) = self.last_pos;

        if let Some(carcass) = self.nearest_carcass().cloned() {
            let dist = (carcass.x - px).hypot(carcass.y - py);
            if dist <= 2.0 {
                self.hunt_phase = Some(HuntPhase::Butcher);
                self.seq += 1;
                self.session
                    .submit_intent(Intent::Harvest {
                        entity_id,
                        node_id: carcass.id,
                        seq: self.seq,
                    })
                    .await?;
                self.stats.intents_sent += 1;
                self.hunt_phase = Some(HuntPhase::Done);
                return Ok(());
            }
        }

        match phase {
            HuntPhase::Seek | HuntPhase::Fight => {
                if let Some(prey) = self.nearest_wildlife().cloned() {
                    let tx = prey.x;
                    let ty = prey.y;
                    let prey_entity = prey.entity_id;
                    let dist = (tx - px).hypot(ty - py);
                    if dist <= 2.0 {
                        self.hunt_phase = Some(HuntPhase::Fight);
                        if self.hunt_target != prey_entity {
                            self.hunt_target = prey_entity;
                            self.seq += 1;
                            if let Some(target_id) = prey_entity {
                                self.session
                                    .submit_intent(Intent::SetTarget {
                                        entity_id,
                                        target_id,
                                        seq: self.seq,
                                    })
                                    .await?;
                                self.stats.intents_sent += 1;
                            }
                        }
                        if self.attack_cooldown == 0 {
                            self.seq += 1;
                            self.session
                                .submit_intent(Intent::Attack {
                                    entity_id,
                                    target_id: prey_entity,
                                    weapon_slot: None,
                                    seq: self.seq,
                                })
                                .await?;
                            self.stats.intents_sent += 1;
                            self.attack_cooldown = 6;
                        } else {
                            self.attack_cooldown = self.attack_cooldown.saturating_sub(1);
                        }
                        return Ok(());
                    }

                    let dx = tx - px;
                    let dy = ty - py;
                    let len = (dx * dx + dy * dy).sqrt().max(0.001);
                    let forward = dy / len;
                    let strafe = dx / len;
                    self.seq += 1;
                    self.session
                        .submit_intent(Intent::Move {
                            entity_id,
                            forward,
                            strafe,
                            vertical: 0.0,
                            sprint: true,
                            seq: self.seq,
                        })
                        .await?;
                    self.stats.intents_sent += 1;
                    return Ok(());
                }
            }
            HuntPhase::Butcher | HuntPhase::Done => {}
        }

        self.send_random_move().await
    }

    async fn send_random_move(&mut self) -> anyhow::Result<()> {
        self.seq += 1;
        let forward = self.rng.gen_range(-1.0..=1.0);
        let strafe = self.rng.gen_range(-1.0..=1.0);
        let seq = self.seq;

        self.session
            .submit_intent(Intent::Move {
                entity_id: self.session.entity_id(),
                forward,
                strafe,
                vertical: 0.0,
                sprint: false,
                seq,
            })
            .await?;

        self.pending.push(PendingIntent {
            sent_at: Instant::now(),
            seq,
        });
        self.stats.intents_sent += 1;
        Ok(())
    }
}