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