flatland-client-lib 0.1.0

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

use flatland_protocol::{Intent, Seq};
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,
}

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

#[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,
}

pub struct BotClient<S: PlayConnection> {
    config: BotConfig,
    session: S,
    seq: Seq,
    stats: BotStats,
    pending: Vec<PendingIntent>,
    rng: StdRng,
}

impl<S: PlayConnection> BotClient<S> {
    pub fn new(config: BotConfig, session: S) -> Self {
        let seed = session.entity_id() ^ session.session_id().rotate_left(17);
        Self {
            config,
            session,
            seq: 0,
            stats: BotStats::default(),
            pending: Vec::new(),
            rng: StdRng::seed_from_u64(seed),
        }
    }

    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<()> {
        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,
                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, .. } => {
                debug!(bot = %self.config.name, entity_id, "welcome");
            }
            SessionEvent::Tick(delta) => {
                self.stats.ticks_received += 1;
                self.stats.last_tick = delta.tick;
                self.stats.last_entity_count = delta.entities.len();
            }
            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::Disconnected => {
                anyhow::bail!("disconnected");
            }
        }
        Ok(())
    }
}