flatland-client-lib 0.2.13

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Headless connect probe (no TTY). Run:
//! `cargo test -p flatland-client-lib connect_stress -- --nocapture`

use std::time::Duration;

use flatland_client_lib::{connect, ConnectOptions, GameClient};
use flatland_protocol::AuthCredential;

#[tokio::test]
#[ignore = "requires dev server on 127.0.0.1:7373 (make dev-server)"]
async fn dev_local_stays_connected_and_receives_world() {
    let opts = ConnectOptions::to_server("probe", "127.0.0.1:7373".parse().unwrap());
    let session = connect(opts).await.expect("connect");
    let mut client = GameClient::new(session);
    client.wait_until_ready().await.expect("welcome");

    assert!(
        !client.state.resource_nodes.is_empty(),
        "welcome snapshot should include resource nodes, got {}",
        client.state.resource_nodes.len()
    );
    assert_eq!(client.state.world_width_m, 256.0);

    let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
    while tokio::time::Instant::now() < deadline {
        if let Some(ev) = client.next_event().await {
            client.handle_event(ev).await.expect("event");
            if !client.state.connected {
                panic!("disconnected early: {:?}", client.state.logs.back());
            }
        }
    }

    assert!(client.state.ticks_received > 20, "expected ticks");
    assert!(client.state.connected);
    client.disconnect();
}

#[tokio::test]
#[ignore = "requires dev server, control plane, and ~/.config/flatland/session.json"]
async fn session_auth_stays_connected_when_logged_in() {
    let path = dirs::config_dir()
        .map(|p| p.join("flatland").join("session.json"))
        .filter(|p| p.exists())
        .or_else(|| {
            dirs::data_dir()
                .map(|p| p.join("flatland").join("session.json"))
                .filter(|p| p.exists())
        });

    let Some(path) = path else {
        eprintln!("skip session_auth_stays_connected_when_logged_in: no session.json");
        return;
    };

    #[derive(serde::Deserialize)]
    struct SessionConfig {
        session_token: String,
    }

    let raw = std::fs::read_to_string(&path).expect("read session");
    let session_cfg: SessionConfig = serde_json::from_str(&raw).expect("parse session");

    let api_base =
        std::env::var("FLATLAND_API_URL").unwrap_or_else(|_| "http://127.0.0.1:7380".into());
    let client = reqwest::Client::new();
    #[derive(serde::Deserialize)]
    struct Envelope<T> {
        data: T,
    }
    #[derive(serde::Deserialize)]
    struct CharList {
        characters: Vec<CharRow>,
    }
    #[derive(serde::Deserialize)]
    struct CharRow {
        id: uuid::Uuid,
        name: String,
        position_x: f32,
        position_y: f32,
    }

    let list: Envelope<CharList> = client
        .get(format!("{api_base}/v1/characters"))
        .header(
            "Authorization",
            format!("Bearer {}", session_cfg.session_token),
        )
        .send()
        .await
        .expect("characters request")
        .json()
        .await
        .expect("characters json");

    let character = list
        .data
        .characters
        .first()
        .expect("at least one character");

    eprintln!(
        "connecting as {} at ({}, {})",
        character.name, character.position_x, character.position_y
    );

    let opts = ConnectOptions::to_server(&character.name, "127.0.0.1:7373".parse().unwrap())
        .with_auth(
            AuthCredential::Session {
                token: session_cfg.session_token,
            },
            Some(character.id),
        );

    let session = connect(opts).await.expect("connect");
    let mut client = GameClient::new(session);
    client.wait_until_ready().await.expect("welcome");

    let (px, py) = client.state.player_position();
    eprintln!(
        "spawn=({px:.1},{py:.1}) nodes={} buildings={} inside={:?}",
        client.state.resource_nodes.len(),
        client.state.buildings.len(),
        client.state.effective_inside_building()
    );

    let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
    while tokio::time::Instant::now() < deadline {
        if let Some(ev) = client.next_event().await {
            client.handle_event(ev).await.expect("event");
            if !client.state.connected {
                panic!(
                    "session auth disconnected: {:?}",
                    client.state.logs.iter().rev().take(3).collect::<Vec<_>>()
                );
            }
        }
    }

    assert!(client.state.ticks_received > 20);
    client.disconnect();
}