Skip to main content

flatland_cli/
characters.rs

1//! Character list / create shared by CLI and gfx onboarding.
2
3use uuid::Uuid;
4
5use crate::api::ControlPlaneClient;
6use crate::auth_context::load_play_auth;
7use crate::config::SessionConfig;
8
9#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
10pub struct CharacterSummary {
11    pub id: Uuid,
12    pub name: String,
13}
14
15#[derive(serde::Deserialize)]
16struct CharacterList {
17    characters: Vec<CharacterSummary>,
18}
19
20#[derive(serde::Deserialize)]
21struct CreateCharacter {
22    character: CharacterSummary,
23}
24
25/// Shared client-side check (server enforces the same rules).
26pub fn validate_character_name(name: &str) -> anyhow::Result<()> {
27    let name = name.trim();
28    if name.is_empty() || name.len() > 32 {
29        anyhow::bail!("character name must be 1–32 characters");
30    }
31    let mut chars = name.chars();
32    let Some(first) = chars.next() else {
33        anyhow::bail!("character name must be 1–32 characters");
34    };
35    if !first.is_ascii_alphabetic() {
36        anyhow::bail!("character name must start with a letter (A–Z)");
37    }
38    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
39        anyhow::bail!(
40            "character name may only contain letters, digits, '_' and '-' (no spaces or ':')"
41        );
42    }
43    Ok(())
44}
45
46pub async fn list_characters() -> anyhow::Result<Vec<CharacterSummary>> {
47    let auth = load_play_auth()?;
48    let client = ControlPlaneClient::new(&auth.api_base);
49    let data: CharacterList = client.get("/v1/characters", &auth.bearer).await?;
50    Ok(data.characters)
51}
52
53pub async fn create_character(name: &str) -> anyhow::Result<CharacterSummary> {
54    let name = name.trim();
55    validate_character_name(name)?;
56
57    let auth = load_play_auth()?;
58    let client = ControlPlaneClient::new(&auth.api_base);
59    let created: CreateCharacter = client
60        .post(
61            "/v1/characters",
62            &serde_json::json!({ "name": name }),
63            Some(&auth.bearer),
64        )
65        .await?;
66
67    if let Ok(mut session) = SessionConfig::load() {
68        let _ = session.remember_character(created.character.id);
69    }
70
71    Ok(created.character)
72}
73
74#[allow(dead_code)] // used by flatland3-gfx onboarding via the lib crate
75pub fn remember_character(id: Uuid) -> anyhow::Result<()> {
76    let mut session = SessionConfig::load()?;
77    session.remember_character(id)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::validate_character_name;
83
84    #[test]
85    fn accepts_simple_names() {
86        assert!(validate_character_name("madsin").is_ok());
87        assert!(validate_character_name("Mad_Sin-2").is_ok());
88    }
89
90    #[test]
91    fn rejects_colon_junk() {
92        assert!(validate_character_name("madsin:3A").is_err());
93        assert!(validate_character_name("mad sin").is_err());
94        assert!(validate_character_name("1mad").is_err());
95    }
96}