Skip to main content

flatland_client_lib/
client_config.rs

1//! Persistent client settings (game host, API base) for installed play.
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8pub const DEFAULT_GAME_PORT: u16 = 7373;
9pub const DEFAULT_API_PORT: u16 = 7380;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct GfxWindowPrefs {
13    /// Last window width in logical pixels.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub width: Option<u32>,
16    /// Last window height in logical pixels.
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub height: Option<u32>,
19    /// Window X (platform coordinates; macOS AppKit bottom-left origin).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub x: Option<u32>,
22    /// Window Y.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub y: Option<u32>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct ClientConfig {
29    /// Gateway host (IPv4, IPv6, or DNS name). Port defaults to 7373.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub game_host: Option<String>,
32    /// Control plane HTTP port when `game_host` is set. Defaults to 7380.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub api_port: Option<u16>,
35    /// Gateway TCP port when `game_host` is set. Defaults to 7373.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub game_port: Option<u16>,
38    /// Gfx play window geometry (`flatland3-gfx`).
39    #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
40    pub gfx_window: GfxWindowPrefs,
41    /// Last HUD view mode from `.` cycle: `normal` | `compact` | `map`.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub hud_view: Option<String>,
44    /// Bottom system LOG dock hidden (`'` toggle).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub hud_log_hidden: Option<bool>,
47    /// Hired workers (`h`) menu compact vs detail (`c` toggle).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub workers_menu_compact: Option<bool>,
50    /// Worker route editor sheet collapsed to the corner chip (`z` toggle).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub worker_route_panel_collapsed: Option<bool>,
53}
54
55impl GfxWindowPrefs {
56    pub fn is_empty(&self) -> bool {
57        self.width.is_none()
58            && self.height.is_none()
59            && self.x.is_none()
60            && self.y.is_none()
61    }
62}
63
64impl ClientConfig {
65    pub fn path() -> anyhow::Result<PathBuf> {
66        let base = dirs::config_dir()
67            .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
68        Ok(base.join("flatland").join("client.json"))
69    }
70
71    pub fn load() -> Self {
72        Self::path()
73            .ok()
74            .and_then(|path| std::fs::read(&path).ok())
75            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
76            .unwrap_or_default()
77    }
78
79    pub fn save(&self) -> anyhow::Result<()> {
80        let path = Self::path()?;
81        if let Some(parent) = path.parent() {
82            std::fs::create_dir_all(parent)?;
83        }
84        std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
85        Ok(())
86    }
87
88    pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
89        self.gfx_window = prefs;
90        self.save()
91    }
92
93    /// Persist the `.` HUD view mode (`normal` / `compact` / `map`).
94    pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
95        self.hud_view = Some(label.to_string());
96        self.save()
97    }
98
99    pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
100        self.hud_log_hidden = Some(hidden);
101        self.save()
102    }
103
104    pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
105        self.workers_menu_compact = Some(compact);
106        self.save()
107    }
108
109    pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
110        self.worker_route_panel_collapsed = Some(collapsed);
111        self.save()
112    }
113
114    pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
115        let host = normalize_host_input(&host.into())?;
116        let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
117        let (host_only, port) = split_host_port(&host, default_port);
118        self.game_host = Some(host_only);
119        if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
120            self.game_port = Some(port);
121        }
122        self.save()
123    }
124
125    /// Host string for UI (never empty — falls back to localhost).
126    pub fn display_game_host(&self) -> String {
127        self.game_host
128            .clone()
129            .unwrap_or_else(|| "127.0.0.1".into())
130    }
131
132    /// Resolve gateway TCP address (supports DNS names like `server1.flatland3.com`).
133    pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
134        use std::net::ToSocketAddrs;
135        let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
136        let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
137        let (host, port) = split_host_port(host, port);
138        let target = format!("{host}:{port}");
139        target
140            .to_socket_addrs()
141            .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
142            .next()
143            .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
144    }
145
146    pub fn game_server_addr(&self) -> SocketAddr {
147        self.resolve_game_server_addr().unwrap_or_else(|_| {
148            "127.0.0.1:7373"
149                .parse()
150                .expect("valid default addr")
151        })
152    }
153
154    pub fn api_base_url(&self) -> String {
155        if let Some(host) = self.game_host.as_deref() {
156            let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
157            if host.starts_with("http://") || host.starts_with("https://") {
158                return host.trim_end_matches('/').to_string();
159            }
160            let (host, _) = split_host_port(host, port);
161            return format!("http://{host}:{port}");
162        }
163        "http://127.0.0.1:7380".into()
164    }
165}
166
167pub fn default_game_server_addr() -> SocketAddr {
168    if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
169        if let Ok(parsed) = addr.parse() {
170            return parsed;
171        }
172    }
173    ClientConfig::load().game_server_addr()
174}
175
176pub fn default_api_base_url() -> String {
177    if let Ok(url) = std::env::var("FLATLAND_API_URL") {
178        return url;
179    }
180    ClientConfig::load().api_base_url()
181}
182
183/// Strip scheme/path and reject empty hosts. Allows DNS names and `host:port`.
184fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
185    let mut host = raw.trim().to_string();
186    if host.is_empty() {
187        anyhow::bail!("game host cannot be empty");
188    }
189    if let Some(rest) = host.strip_prefix("https://") {
190        host = rest.to_string();
191    } else if let Some(rest) = host.strip_prefix("http://") {
192        host = rest.to_string();
193    }
194    if let Some((h, _)) = host.split_once('/') {
195        host = h.to_string();
196    }
197    host = host.trim_end_matches('/').to_string();
198    if host.is_empty() {
199        anyhow::bail!("game host cannot be empty");
200    }
201    Ok(host)
202}
203
204fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
205    // IPv6 in brackets: [2001:db8::1]:7373
206    if let Some(rest) = host.strip_prefix('[') {
207        if let Some((addr, port_part)) = rest.split_once("]:") {
208            if let Ok(p) = port_part.parse::<u16>() {
209                return (format!("[{addr}]"), p);
210            }
211        }
212        return (host.to_string(), default_port);
213    }
214    // host:port — only split on the last colon when the port is numeric
215    if let Some((h, p)) = host.rsplit_once(':') {
216        if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
217            return (h.to_string(), p.parse().unwrap_or(default_port));
218        }
219    }
220    (host.to_string(), default_port)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn default_is_localhost() {
229        let cfg = ClientConfig::default();
230        assert_eq!(
231            cfg.game_server_addr(),
232            "127.0.0.1:7373".parse().unwrap()
233        );
234        assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
235    }
236
237    #[test]
238    fn hud_view_roundtrips_in_config() {
239        let mut cfg = ClientConfig::default();
240        cfg.hud_view = Some("map".into());
241        let json = serde_json::to_string(&cfg).unwrap();
242        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
243        assert_eq!(loaded.hud_view.as_deref(), Some("map"));
244    }
245
246    #[test]
247    fn hud_and_workers_prefs_roundtrip() {
248        let mut cfg = ClientConfig::default();
249        cfg.hud_log_hidden = Some(true);
250        cfg.workers_menu_compact = Some(true);
251        cfg.worker_route_panel_collapsed = Some(true);
252        let json = serde_json::to_string(&cfg).unwrap();
253        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
254        assert_eq!(loaded.hud_log_hidden, Some(true));
255        assert_eq!(loaded.workers_menu_compact, Some(true));
256        assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
257    }
258
259    #[test]
260    fn split_host_port_handles_dns_and_explicit_port() {
261        assert_eq!(
262            split_host_port("server1.flatland3.com", 7373),
263            ("server1.flatland3.com".into(), 7373)
264        );
265        assert_eq!(
266            split_host_port("server1.flatland3.com:7374", 7373),
267            ("server1.flatland3.com".into(), 7374)
268        );
269        assert_eq!(
270            split_host_port("127.0.0.1", 7373),
271            ("127.0.0.1".into(), 7373)
272        );
273    }
274
275    #[test]
276    fn normalize_strips_scheme() {
277        assert_eq!(
278            normalize_host_input("https://server1.flatland3.com/").unwrap(),
279            "server1.flatland3.com"
280        );
281    }
282
283    #[test]
284    fn api_base_uses_dns_host() {
285        let mut cfg = ClientConfig::default();
286        cfg.game_host = Some("server1.flatland3.com".into());
287        assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
288    }
289}