flatland-client-lib 0.2.13

Flatland3 remote game client library (TCP session, bots, game state)
Documentation
//! Persistent client settings (game host, API base) for installed play.

use std::net::SocketAddr;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

pub const DEFAULT_GAME_PORT: u16 = 7373;
pub const DEFAULT_API_PORT: u16 = 7380;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GfxWindowPrefs {
    /// Last window width in logical pixels.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub width: Option<u32>,
    /// Last window height in logical pixels.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub height: Option<u32>,
    /// Window X (platform coordinates; macOS AppKit bottom-left origin).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub x: Option<u32>,
    /// Window Y.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub y: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientConfig {
    /// Gateway host (IPv4, IPv6, or DNS name). Port defaults to 7373.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub game_host: Option<String>,
    /// Control plane HTTP port when `game_host` is set. Defaults to 7380.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_port: Option<u16>,
    /// Gateway TCP port when `game_host` is set. Defaults to 7373.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub game_port: Option<u16>,
    /// Gfx play window geometry (`flatland3-gfx`).
    #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
    pub gfx_window: GfxWindowPrefs,
    /// Last HUD view mode from `.` cycle: `normal` | `compact` | `map`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hud_view: Option<String>,
}

impl GfxWindowPrefs {
    pub fn is_empty(&self) -> bool {
        self.width.is_none()
            && self.height.is_none()
            && self.x.is_none()
            && self.y.is_none()
    }
}

impl ClientConfig {
    pub fn path() -> anyhow::Result<PathBuf> {
        let base = dirs::config_dir()
            .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
        Ok(base.join("flatland").join("client.json"))
    }

    pub fn load() -> Self {
        Self::path()
            .ok()
            .and_then(|path| std::fs::read(&path).ok())
            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
            .unwrap_or_default()
    }

    pub fn save(&self) -> anyhow::Result<()> {
        let path = Self::path()?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
        Ok(())
    }

    pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
        self.gfx_window = prefs;
        self.save()
    }

    /// Persist the `.` HUD view mode (`normal` / `compact` / `map`).
    pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
        self.hud_view = Some(label.to_string());
        self.save()
    }

    pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
        let host = host.into().trim().to_string();
        if host.is_empty() {
            anyhow::bail!("game host cannot be empty");
        }
        self.game_host = Some(host);
        self.save()
    }

    pub fn game_server_addr(&self) -> SocketAddr {
        if let Some(host) = self.game_host.as_deref() {
            let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
            return parse_host_port(host, port)
                .unwrap_or_else(|| format!("{host}:{port}").parse().expect("valid host:port"));
        }
        "127.0.0.1:7373".parse().expect("valid default addr")
    }

    pub fn api_base_url(&self) -> String {
        if let Some(host) = self.game_host.as_deref() {
            let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
            if host.starts_with("http://") || host.starts_with("https://") {
                return host.to_string();
            }
            return format!("http://{host}:{port}");
        }
        "http://127.0.0.1:7380".into()
    }
}

pub fn default_game_server_addr() -> SocketAddr {
    if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
        if let Ok(parsed) = addr.parse() {
            return parsed;
        }
    }
    ClientConfig::load().game_server_addr()
}

pub fn default_api_base_url() -> String {
    if let Ok(url) = std::env::var("FLATLAND_API_URL") {
        return url;
    }
    ClientConfig::load().api_base_url()
}

fn parse_host_port(host: &str, default_port: u16) -> Option<SocketAddr> {
    if host.contains(':') {
        host.parse().ok()
    } else {
        format!("{host}:{default_port}").parse().ok()
    }
}

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

    #[test]
    fn default_is_localhost() {
        let cfg = ClientConfig::default();
        assert_eq!(
            cfg.game_server_addr(),
            "127.0.0.1:7373".parse().unwrap()
        );
        assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
    }

    #[test]
    fn hud_view_roundtrips_in_config() {
        let mut cfg = ClientConfig::default();
        cfg.hud_view = Some("map".into());
        let json = serde_json::to_string(&cfg).unwrap();
        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
        assert_eq!(loaded.hud_view.as_deref(), Some("map"));
    }
}