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