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    /// When false, skip automatic client binary update checks (default true).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub check_updates: Option<bool>,
79    /// Last remote version the player dismissed with "Remind later".
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub update_dismissed_version: Option<String>,
82    /// Auto-nav route mode: `"fastest"` (true ETA / roads) or `"direct"` (shortest geometry).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub auto_nav_mode: Option<String>,
85}
86
87impl GfxWindowPrefs {
88    pub fn is_empty(&self) -> bool {
89        self.width.is_none() && self.height.is_none() && self.x.is_none() && self.y.is_none()
90    }
91}
92
93impl ClientConfig {
94    pub fn path() -> anyhow::Result<PathBuf> {
95        let base = dirs::config_dir()
96            .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
97        Ok(base.join("flatland").join("client.json"))
98    }
99
100    pub fn load() -> Self {
101        Self::path()
102            .ok()
103            .and_then(|path| std::fs::read(&path).ok())
104            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
105            .unwrap_or_default()
106    }
107
108    pub fn save(&self) -> anyhow::Result<()> {
109        let path = Self::path()?;
110        if let Some(parent) = path.parent() {
111            std::fs::create_dir_all(parent)?;
112        }
113        std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
114        Ok(())
115    }
116
117    pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
118        self.gfx_window = prefs;
119        self.save()
120    }
121
122    /// Persist map-mode floating chat position + collapse.
123    pub fn save_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
124        self.floating_chat = prefs;
125        self.save()
126    }
127
128    /// Persist the `.` HUD view mode (`normal` / `compact` / `map`).
129    pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
130        self.hud_view = Some(label.to_string());
131        self.save()
132    }
133
134    pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
135        self.hud_log_hidden = Some(hidden);
136        self.save()
137    }
138
139    pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
140        self.workers_menu_compact = Some(compact);
141        self.save()
142    }
143
144    pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
145        self.worker_route_panel_collapsed = Some(collapsed);
146        self.save()
147    }
148
149    pub fn save_update_dismissed_version(&mut self, version: &str) -> anyhow::Result<()> {
150        self.update_dismissed_version = Some(version.to_string());
151        self.save()
152    }
153
154    /// Auto-nav A* mode (default Fastest).
155    pub fn auto_nav_path_mode(&self) -> flatland_pathfinding::PathMode {
156        self.auto_nav_mode
157            .as_deref()
158            .map(flatland_pathfinding::PathMode::parse)
159            .unwrap_or_default()
160    }
161
162    pub fn save_auto_nav_mode(
163        &mut self,
164        mode: flatland_pathfinding::PathMode,
165    ) -> anyhow::Result<()> {
166        self.auto_nav_mode = Some(mode.as_str().to_string());
167        self.save()
168    }
169
170    pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
171        let host = normalize_host_input(&host.into())?;
172        let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
173        let (host_only, port) = split_host_port(&host, default_port);
174        self.game_host = Some(host_only);
175        if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
176            self.game_port = Some(port);
177        }
178        self.save()
179    }
180
181    /// Host string for UI (never empty — falls back to localhost).
182    pub fn display_game_host(&self) -> String {
183        self.game_host.clone().unwrap_or_else(|| "127.0.0.1".into())
184    }
185
186    /// Resolve gateway TCP address (supports DNS names like `server1.flatland3.com`).
187    pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
188        use std::net::ToSocketAddrs;
189        let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
190        let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
191        let (host, port) = split_host_port(host, port);
192        let target = format!("{host}:{port}");
193        target
194            .to_socket_addrs()
195            .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
196            .next()
197            .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
198    }
199
200    pub fn game_server_addr(&self) -> SocketAddr {
201        self.resolve_game_server_addr()
202            .unwrap_or_else(|_| "127.0.0.1:7373".parse().expect("valid default addr"))
203    }
204
205    pub fn api_base_url(&self) -> String {
206        if let Some(host) = self.game_host.as_deref() {
207            let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
208            if host.starts_with("http://") || host.starts_with("https://") {
209                return host.trim_end_matches('/').to_string();
210            }
211            let (host, _) = split_host_port(host, port);
212            return format!("http://{host}:{port}");
213        }
214        "http://127.0.0.1:7380".into()
215    }
216}
217
218pub fn default_game_server_addr() -> SocketAddr {
219    if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
220        if let Ok(parsed) = addr.parse() {
221            return parsed;
222        }
223        // DNS host (optionally :port) via env — same rules as --host.
224        if let Ok(resolved) = resolve_game_host_addr(&addr) {
225            return resolved;
226        }
227    }
228    ClientConfig::load().game_server_addr()
229}
230
231pub fn default_api_base_url() -> String {
232    if let Ok(url) = std::env::var("FLATLAND_API_URL") {
233        return url;
234    }
235    ClientConfig::load().api_base_url()
236}
237
238/// Resolve a DNS name or IP (optional `:port`) to the gateway TCP address.
239///
240/// Port defaults to [`DEFAULT_GAME_PORT`] (7373). Does not read or write `client.json`.
241pub fn resolve_game_host_addr(host: &str) -> anyhow::Result<SocketAddr> {
242    use std::net::ToSocketAddrs;
243    let host = normalize_host_input(host)?;
244    let (host, port) = split_host_port(&host, DEFAULT_GAME_PORT);
245    let target = format!("{host}:{port}");
246    target
247        .to_socket_addrs()
248        .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
249        .next()
250        .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
251}
252
253/// Control-plane base URL for a DNS/IP host (optional `:port` is ignored for API;
254/// API always uses [`DEFAULT_API_PORT`] unless the host is already an `http(s)://` URL).
255pub fn api_base_url_for_host(host: &str) -> anyhow::Result<String> {
256    let host = normalize_host_input(host)?;
257    if host.starts_with("http://") || host.starts_with("https://") {
258        return Ok(host.trim_end_matches('/').to_string());
259    }
260    let (host, _) = split_host_port(&host, DEFAULT_API_PORT);
261    Ok(format!("http://{host}:{DEFAULT_API_PORT}"))
262}
263
264/// Apply a one-shot host for this process only (no `client.json` write).
265///
266/// Sets `FLATLAND_API_URL`, `FLATLAND_GATEWAY_ADDR`, and `FLATLAND_HOST_OVERRIDE`
267/// so auth, asset sync, and gateway connect use the same remote without touching
268/// the machine's saved game host. Also points `FLATLAND_SESSION_PATH` at a
269/// host-scoped session file so the default `session.json` stays for local play.
270pub fn apply_host_override(host: &str) -> anyhow::Result<(SocketAddr, String)> {
271    let normalized = normalize_host_input(host)?;
272    let game = resolve_game_host_addr(&normalized)?;
273    let api = api_base_url_for_host(&normalized)?;
274    let (host_only, _) = split_host_port(&normalized, DEFAULT_GAME_PORT);
275
276    std::env::set_var("FLATLAND_HOST_OVERRIDE", &host_only);
277    std::env::set_var("FLATLAND_API_URL", &api);
278    std::env::set_var("FLATLAND_GATEWAY_ADDR", game.to_string());
279
280    if std::env::var_os("FLATLAND_SESSION_PATH").is_none() {
281        if let Some(base) = dirs::config_dir() {
282            let safe: String = host_only
283                .chars()
284                .map(|c| {
285                    if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
286                        c
287                    } else {
288                        '_'
289                    }
290                })
291                .collect();
292            let path = base.join("flatland").join(format!("session.{safe}.json"));
293            std::env::set_var("FLATLAND_SESSION_PATH", path);
294        }
295    }
296
297    Ok((game, api))
298}
299
300/// True when this process applied (or inherited) a one-shot `--host` override.
301pub fn host_override_active() -> bool {
302    std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
303}
304
305/// Strip scheme/path and reject empty hosts. Allows DNS names and `host:port`.
306pub fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
307    let mut host = raw.trim().to_string();
308    if host.is_empty() {
309        anyhow::bail!("game host cannot be empty");
310    }
311    if let Some(rest) = host.strip_prefix("https://") {
312        host = rest.to_string();
313    } else if let Some(rest) = host.strip_prefix("http://") {
314        host = rest.to_string();
315    }
316    if let Some((h, _)) = host.split_once('/') {
317        host = h.to_string();
318    }
319    host = host.trim_end_matches('/').to_string();
320    if host.is_empty() {
321        anyhow::bail!("game host cannot be empty");
322    }
323    Ok(host)
324}
325
326fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
327    // IPv6 in brackets: [2001:db8::1]:7373
328    if let Some(rest) = host.strip_prefix('[') {
329        if let Some((addr, port_part)) = rest.split_once("]:") {
330            if let Ok(p) = port_part.parse::<u16>() {
331                return (format!("[{addr}]"), p);
332            }
333        }
334        return (host.to_string(), default_port);
335    }
336    // host:port — only split on the last colon when the port is numeric
337    if let Some((h, p)) = host.rsplit_once(':') {
338        if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
339            return (h.to_string(), p.parse().unwrap_or(default_port));
340        }
341    }
342    (host.to_string(), default_port)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn default_is_localhost() {
351        let cfg = ClientConfig::default();
352        assert_eq!(cfg.game_server_addr(), "127.0.0.1:7373".parse().unwrap());
353        assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
354    }
355
356    #[test]
357    fn hud_view_roundtrips_in_config() {
358        let mut cfg = ClientConfig::default();
359        cfg.hud_view = Some("map".into());
360        let json = serde_json::to_string(&cfg).unwrap();
361        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
362        assert_eq!(loaded.hud_view.as_deref(), Some("map"));
363    }
364
365    #[test]
366    fn hud_and_workers_prefs_roundtrip() {
367        let mut cfg = ClientConfig::default();
368        cfg.hud_log_hidden = Some(true);
369        cfg.workers_menu_compact = Some(true);
370        cfg.worker_route_panel_collapsed = Some(true);
371        let json = serde_json::to_string(&cfg).unwrap();
372        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
373        assert_eq!(loaded.hud_log_hidden, Some(true));
374        assert_eq!(loaded.workers_menu_compact, Some(true));
375        assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
376    }
377
378    #[test]
379    fn floating_chat_prefs_roundtrip() {
380        let mut cfg = ClientConfig::default();
381        cfg.floating_chat = FloatingChatPrefs {
382            x: Some(42.5),
383            y: Some(100.0),
384            collapsed: Some(true),
385        };
386        let json = serde_json::to_string(&cfg).unwrap();
387        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
388        assert_eq!(loaded.floating_chat.x, Some(42.5));
389        assert_eq!(loaded.floating_chat.y, Some(100.0));
390        assert_eq!(loaded.floating_chat.collapsed, Some(true));
391    }
392
393    #[test]
394    fn split_host_port_handles_dns_and_explicit_port() {
395        assert_eq!(
396            split_host_port("server1.flatland3.com", 7373),
397            ("server1.flatland3.com".into(), 7373)
398        );
399        assert_eq!(
400            split_host_port("server1.flatland3.com:7374", 7373),
401            ("server1.flatland3.com".into(), 7374)
402        );
403        assert_eq!(
404            split_host_port("127.0.0.1", 7373),
405            ("127.0.0.1".into(), 7373)
406        );
407    }
408
409    #[test]
410    fn normalize_strips_scheme() {
411        assert_eq!(
412            normalize_host_input("https://server1.flatland3.com/").unwrap(),
413            "server1.flatland3.com"
414        );
415    }
416
417    #[test]
418    fn api_base_uses_dns_host() {
419        let mut cfg = ClientConfig::default();
420        cfg.game_host = Some("server1.flatland3.com".into());
421        assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
422    }
423
424    #[test]
425    fn api_base_url_for_host_defaults_port() {
426        assert_eq!(
427            api_base_url_for_host("server2.flatland3.com").unwrap(),
428            "http://server2.flatland3.com:7380"
429        );
430        assert_eq!(
431            api_base_url_for_host("server2.flatland3.com:7373").unwrap(),
432            "http://server2.flatland3.com:7380"
433        );
434    }
435}