flatland3 0.2.12

Flatland3 terminal play client and account CLI
Documentation
//! Character list / create shared by CLI and gfx onboarding.

use uuid::Uuid;

use crate::api::ControlPlaneClient;
use crate::auth_context::load_play_auth;
use crate::config::SessionConfig;

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct CharacterSummary {
    pub id: Uuid,
    pub name: String,
}

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

#[derive(serde::Deserialize)]
struct CreateCharacter {
    character: CharacterSummary,
}

/// Shared client-side check (server enforces the same rules).
pub fn validate_character_name(name: &str) -> anyhow::Result<()> {
    let name = name.trim();
    if name.is_empty() || name.len() > 32 {
        anyhow::bail!("character name must be 1–32 characters");
    }
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        anyhow::bail!("character name must be 1–32 characters");
    };
    if !first.is_ascii_alphabetic() {
        anyhow::bail!("character name must start with a letter (A–Z)");
    }
    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
        anyhow::bail!(
            "character name may only contain letters, digits, '_' and '-' (no spaces or ':')"
        );
    }
    Ok(())
}

pub async fn list_characters() -> anyhow::Result<Vec<CharacterSummary>> {
    let auth = load_play_auth()?;
    let client = ControlPlaneClient::new(&auth.api_base);
    let data: CharacterList = client.get("/v1/characters", &auth.bearer).await?;
    Ok(data.characters)
}

pub async fn create_character(name: &str) -> anyhow::Result<CharacterSummary> {
    let name = name.trim();
    validate_character_name(name)?;

    let auth = load_play_auth()?;
    let client = ControlPlaneClient::new(&auth.api_base);
    let created: CreateCharacter = client
        .post(
            "/v1/characters",
            &serde_json::json!({ "name": name }),
            Some(&auth.bearer),
        )
        .await?;

    if let Ok(mut session) = SessionConfig::load() {
        let _ = session.remember_character(created.character.id);
    }

    Ok(created.character)
}

#[allow(dead_code)] // used by flatland3-gfx onboarding via the lib crate
pub fn remember_character(id: Uuid) -> anyhow::Result<()> {
    let mut session = SessionConfig::load()?;
    session.remember_character(id)
}

#[cfg(test)]
mod tests {
    use super::validate_character_name;

    #[test]
    fn accepts_simple_names() {
        assert!(validate_character_name("madsin").is_ok());
        assert!(validate_character_name("Mad_Sin-2").is_ok());
    }

    #[test]
    fn rejects_colon_junk() {
        assert!(validate_character_name("madsin:3A").is_err());
        assert!(validate_character_name("mad sin").is_err());
        assert!(validate_character_name("1mad").is_err());
    }
}