flatland3 0.2.1

Flatland3 terminal play client and account CLI
use flatland_protocol::AuthCredential;
use uuid::Uuid;

use crate::api::ControlPlaneClient;
use crate::auth_context::{load_play_auth, PlayAuth, PlayAuthKind};
use crate::config::SessionConfig;
use flatland_client_lib::ConnectOptions;

#[derive(serde::Deserialize)]
struct CharacterList {
    characters: Vec<CharacterRow>,
}

#[derive(serde::Deserialize, Clone)]
struct CharacterRow {
    id: Uuid,
    name: String,
}

/// Resolve auth + an **existing** character for remote play.
///
/// Never creates characters. Use `flatland3 character create <Name>` first.
pub async fn prepare_remote_play(
    character_name: Option<&str>,
    server: std::net::SocketAddr,
) -> anyhow::Result<ConnectOptions> {
    let auth = load_play_auth()?;
    let client = ControlPlaneClient::new(&auth.api_base);
    let character = resolve_existing_character(&auth, &client, character_name).await?;

    if auth.kind == PlayAuthKind::Session {
        if let Ok(mut session) = SessionConfig::load() {
            let _ = session.remember_character(character.id);
        }
    }

    let credential = match auth.kind {
        PlayAuthKind::Session => AuthCredential::Session {
            token: auth.bearer,
        },
        PlayAuthKind::ApiToken => AuthCredential::ApiToken {
            token: auth.bearer,
            character_id: character.id,
        },
    };

    // Hello.client_name is display-only; character_id is authoritative for persistence.
    Ok(
        ConnectOptions::to_server(&character.name, server).with_auth(credential, Some(character.id)),
    )
}

async fn resolve_existing_character(
    auth: &PlayAuth,
    client: &ControlPlaneClient,
    character_name: Option<&str>,
) -> anyhow::Result<CharacterRow> {
    let list: CharacterList = client.get("/v1/characters", &auth.bearer).await?;

    if list.characters.is_empty() {
        anyhow::bail!(
            "no characters on this account.\n\
             Create one first (requires login):\n\
               flatland3 character create <Name>\n\
             Then play with:\n\
               flatland3 --name <Name>"
        );
    }

    if let Ok(id_str) = std::env::var("FLATLAND_CHARACTER_ID") {
        if !id_str.is_empty() {
            let id: Uuid = id_str.parse().map_err(|_| {
                anyhow::anyhow!("FLATLAND_CHARACTER_ID must be a valid UUID")
            })?;
            if let Some(row) = list.characters.iter().find(|c| c.id == id) {
                return Ok(row.clone());
            }
            anyhow::bail!("no character with FLATLAND_CHARACTER_ID={id}");
        }
    }

    if let Some(raw) = character_name.map(str::trim).filter(|s| !s.is_empty()) {
        if let Some(existing) = list.characters.iter().find(|c| c.name == raw) {
            return Ok(existing.clone());
        }
        let names: Vec<_> = list.characters.iter().map(|c| c.name.as_str()).collect();
        anyhow::bail!(
            "character \"{raw}\" not found on this account.\n\
             Existing: {}\n\
             Create with: flatland3 character create {raw}\n\
             Or list with: flatland3 character list",
            names.join(", ")
        );
    }

    // No --name: use last played character, else the only character, else ask.
    if auth.kind == PlayAuthKind::Session {
        if let Ok(session) = SessionConfig::load() {
            if let Some(id) = session.last_character_id {
                if let Some(row) = list.characters.iter().find(|c| c.id == id) {
                    return Ok(row.clone());
                }
            }
        }
    }

    if list.characters.len() == 1 {
        return Ok(list.characters[0].clone());
    }

    let names: Vec<_> = list.characters.iter().map(|c| c.name.as_str()).collect();
    anyhow::bail!(
        "multiple characters — pick one:\n\
           flatland3 --name <Name>\n\
         Available: {}\n\
         Or: flatland3 character list",
        names.join(", ")
    )
}