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/// Legacy floating CHAT prefs — migrated into [`FloatingPanelsPrefs::chat`] on load.
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/// One movable floating HUD panel (position, collapse, visibility).
48#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
49pub struct FloatingPanelPrefs {
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub x: Option<f32>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub y: Option<f32>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub collapsed: Option<bool>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub hidden: Option<bool>,
58}
59
60impl FloatingPanelPrefs {
61    pub fn is_empty(&self) -> bool {
62        self.x.is_none() && self.y.is_none() && self.collapsed.is_none() && self.hidden.is_none()
63    }
64}
65
66/// Gfx floating HUD panels — toggled with Ctrl+Shift+1–5.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum FloatingPanelId {
69    Chat = 1,
70    Quest = 2,
71    Location = 3,
72    Log = 4,
73    Stats = 5,
74}
75
76impl FloatingPanelId {
77    pub const ALL: [Self; 5] = [
78        Self::Chat,
79        Self::Quest,
80        Self::Location,
81        Self::Log,
82        Self::Stats,
83    ];
84
85    pub fn from_toggle_slot(slot: u8) -> Option<Self> {
86        match slot {
87            1 => Some(Self::Chat),
88            2 => Some(Self::Quest),
89            3 => Some(Self::Location),
90            4 => Some(Self::Log),
91            5 => Some(Self::Stats),
92            _ => None,
93        }
94    }
95
96    pub fn as_str(self) -> &'static str {
97        match self {
98            Self::Chat => "chat",
99            Self::Quest => "quest",
100            Self::Location => "location",
101            Self::Log => "log",
102            Self::Stats => "stats",
103        }
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
108pub struct FloatingPanelsPrefs {
109    #[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
110    pub chat: FloatingPanelPrefs,
111    #[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
112    pub quest: FloatingPanelPrefs,
113    #[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
114    pub location: FloatingPanelPrefs,
115    #[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
116    pub log: FloatingPanelPrefs,
117    #[serde(default, skip_serializing_if = "FloatingPanelPrefs::is_empty")]
118    pub stats: FloatingPanelPrefs,
119}
120
121impl FloatingPanelsPrefs {
122    pub fn is_empty(&self) -> bool {
123        self.chat.is_empty()
124            && self.quest.is_empty()
125            && self.location.is_empty()
126            && self.log.is_empty()
127            && self.stats.is_empty()
128    }
129
130    pub fn panel(&self, id: FloatingPanelId) -> &FloatingPanelPrefs {
131        match id {
132            FloatingPanelId::Chat => &self.chat,
133            FloatingPanelId::Quest => &self.quest,
134            FloatingPanelId::Location => &self.location,
135            FloatingPanelId::Log => &self.log,
136            FloatingPanelId::Stats => &self.stats,
137        }
138    }
139
140    pub fn panel_mut(&mut self, id: FloatingPanelId) -> &mut FloatingPanelPrefs {
141        match id {
142            FloatingPanelId::Chat => &mut self.chat,
143            FloatingPanelId::Quest => &mut self.quest,
144            FloatingPanelId::Location => &mut self.location,
145            FloatingPanelId::Log => &mut self.log,
146            FloatingPanelId::Stats => &mut self.stats,
147        }
148    }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, Default)]
152pub struct ClientConfig {
153    /// Gateway host (IPv4, IPv6, or DNS name). Port defaults to 7373.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub game_host: Option<String>,
156    /// Control plane HTTP port when `game_host` is set. Defaults to 7380.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub api_port: Option<u16>,
159    /// Gateway TCP port when `game_host` is set. Defaults to 7373.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub game_port: Option<u16>,
162    /// Gfx play window geometry (`flatland3-gfx`).
163    #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
164    pub gfx_window: GfxWindowPrefs,
165    /// Legacy floating chat — migrated into [`Self::floating_panels`].
166    #[serde(default, skip_serializing_if = "FloatingChatPrefs::is_empty")]
167    pub floating_chat: FloatingChatPrefs,
168    /// Floating HUD panels (`flatland3-gfx`) — chat, quest, location, log, stats.
169    #[serde(default, skip_serializing_if = "FloatingPanelsPrefs::is_empty")]
170    pub floating_panels: FloatingPanelsPrefs,
171    /// Last HUD view mode from `.` cycle (legacy; gfx no longer cycles).
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub hud_view: Option<String>,
174    /// Bottom system LOG dock hidden (legacy; migrated to floating log).
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub hud_log_hidden: Option<bool>,
177    /// Hired workers (`h`) menu compact vs detail (`c` toggle).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub workers_menu_compact: Option<bool>,
180    /// Worker route editor sheet collapsed to the corner chip (`z` toggle).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub worker_route_panel_collapsed: Option<bool>,
183    /// When false, skip automatic client binary update checks (default true).
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub check_updates: Option<bool>,
186    /// Last remote version the player dismissed with "Remind later".
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub update_dismissed_version: Option<String>,
189    /// Auto-nav route mode: `"fastest"` (true ETA / roads) or `"direct"` (shortest geometry).
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub auto_nav_mode: Option<String>,
192}
193
194impl GfxWindowPrefs {
195    pub fn is_empty(&self) -> bool {
196        self.width.is_none() && self.height.is_none() && self.x.is_none() && self.y.is_none()
197    }
198}
199
200impl ClientConfig {
201    pub fn path() -> anyhow::Result<PathBuf> {
202        let base = dirs::config_dir()
203            .ok_or_else(|| anyhow::anyhow!("could not resolve config directory"))?;
204        Ok(base.join("flatland").join("client.json"))
205    }
206
207    pub fn load() -> Self {
208        let mut cfg: Self = Self::path()
209            .ok()
210            .and_then(|path| std::fs::read(&path).ok())
211            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
212            .unwrap_or_default();
213        cfg.migrate_floating_panels();
214        cfg
215    }
216
217    /// Copy legacy `floating_chat` / `hud_log_hidden` into [`Self::floating_panels`].
218    pub fn migrate_floating_panels(&mut self) {
219        if !self.floating_chat.is_empty() && self.floating_panels.chat.is_empty() {
220            self.floating_panels.chat = FloatingPanelPrefs {
221                x: self.floating_chat.x,
222                y: self.floating_chat.y,
223                collapsed: self.floating_chat.collapsed,
224                hidden: None,
225            };
226        }
227        if self.hud_log_hidden == Some(true) && self.floating_panels.log.hidden.is_none() {
228            self.floating_panels.log.hidden = Some(true);
229        }
230    }
231
232    pub fn save(&self) -> anyhow::Result<()> {
233        let path = Self::path()?;
234        if let Some(parent) = path.parent() {
235            std::fs::create_dir_all(parent)?;
236        }
237        std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
238        Ok(())
239    }
240
241    pub fn save_gfx_window(&mut self, prefs: GfxWindowPrefs) -> anyhow::Result<()> {
242        self.gfx_window = prefs;
243        self.save()
244    }
245
246    /// Persist map-mode floating chat position + collapse (legacy writers).
247    pub fn save_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
248        self.floating_chat = prefs;
249        self.save()
250    }
251
252    /// Persist one floating HUD panel.
253    pub fn save_floating_panel(
254        &mut self,
255        id: FloatingPanelId,
256        prefs: FloatingPanelPrefs,
257    ) -> anyhow::Result<()> {
258        *self.floating_panels.panel_mut(id) = prefs;
259        self.save()
260    }
261
262    /// Persist the `.` HUD view mode (`normal` / `compact` / `map`).
263    pub fn save_hud_view(&mut self, label: &str) -> anyhow::Result<()> {
264        self.hud_view = Some(label.to_string());
265        self.save()
266    }
267
268    pub fn save_hud_log_hidden(&mut self, hidden: bool) -> anyhow::Result<()> {
269        self.hud_log_hidden = Some(hidden);
270        self.save()
271    }
272
273    pub fn save_workers_menu_compact(&mut self, compact: bool) -> anyhow::Result<()> {
274        self.workers_menu_compact = Some(compact);
275        self.save()
276    }
277
278    pub fn save_worker_route_panel_collapsed(&mut self, collapsed: bool) -> anyhow::Result<()> {
279        self.worker_route_panel_collapsed = Some(collapsed);
280        self.save()
281    }
282
283    pub fn save_update_dismissed_version(&mut self, version: &str) -> anyhow::Result<()> {
284        self.update_dismissed_version = Some(version.to_string());
285        self.save()
286    }
287
288    /// Auto-nav A* mode (default Fastest).
289    pub fn auto_nav_path_mode(&self) -> flatland_pathfinding::PathMode {
290        self.auto_nav_mode
291            .as_deref()
292            .map(flatland_pathfinding::PathMode::parse)
293            .unwrap_or_default()
294    }
295
296    pub fn save_auto_nav_mode(
297        &mut self,
298        mode: flatland_pathfinding::PathMode,
299    ) -> anyhow::Result<()> {
300        self.auto_nav_mode = Some(mode.as_str().to_string());
301        self.save()
302    }
303
304    pub fn set_game_host(&mut self, host: impl Into<String>) -> anyhow::Result<()> {
305        let host = normalize_host_input(&host.into())?;
306        let default_port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
307        let (host_only, port) = split_host_port(&host, default_port);
308        self.game_host = Some(host_only);
309        if port != DEFAULT_GAME_PORT || self.game_port.is_some() {
310            self.game_port = Some(port);
311        }
312        self.save()
313    }
314
315    /// Host string for UI (never empty — falls back to localhost).
316    pub fn display_game_host(&self) -> String {
317        self.game_host.clone().unwrap_or_else(|| "127.0.0.1".into())
318    }
319
320    /// Resolve gateway TCP address (supports DNS names like `server1.flatland3.com`).
321    pub fn resolve_game_server_addr(&self) -> anyhow::Result<std::net::SocketAddr> {
322        use std::net::ToSocketAddrs;
323        let host = self.game_host.as_deref().unwrap_or("127.0.0.1");
324        let port = self.game_port.unwrap_or(DEFAULT_GAME_PORT);
325        let (host, port) = split_host_port(host, port);
326        let target = format!("{host}:{port}");
327        target
328            .to_socket_addrs()
329            .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
330            .next()
331            .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
332    }
333
334    pub fn game_server_addr(&self) -> SocketAddr {
335        self.resolve_game_server_addr()
336            .unwrap_or_else(|_| "127.0.0.1:7373".parse().expect("valid default addr"))
337    }
338
339    pub fn api_base_url(&self) -> String {
340        if let Some(host) = self.game_host.as_deref() {
341            let port = self.api_port.unwrap_or(DEFAULT_API_PORT);
342            if host.starts_with("http://") || host.starts_with("https://") {
343                return host.trim_end_matches('/').to_string();
344            }
345            let (host, _) = split_host_port(host, port);
346            return format!("http://{host}:{port}");
347        }
348        "http://127.0.0.1:7380".into()
349    }
350}
351
352pub fn default_game_server_addr() -> SocketAddr {
353    if let Ok(addr) = std::env::var("FLATLAND_GATEWAY_ADDR") {
354        if let Ok(parsed) = addr.parse() {
355            return parsed;
356        }
357        // DNS host (optionally :port) via env — same rules as --host.
358        if let Ok(resolved) = resolve_game_host_addr(&addr) {
359            return resolved;
360        }
361    }
362    ClientConfig::load().game_server_addr()
363}
364
365pub fn default_api_base_url() -> String {
366    if let Ok(url) = std::env::var("FLATLAND_API_URL") {
367        return url;
368    }
369    ClientConfig::load().api_base_url()
370}
371
372/// Resolve a DNS name or IP (optional `:port`) to the gateway TCP address.
373///
374/// Port defaults to [`DEFAULT_GAME_PORT`] (7373). Does not read or write `client.json`.
375pub fn resolve_game_host_addr(host: &str) -> anyhow::Result<SocketAddr> {
376    use std::net::ToSocketAddrs;
377    let host = normalize_host_input(host)?;
378    let (host, port) = split_host_port(&host, DEFAULT_GAME_PORT);
379    let target = format!("{host}:{port}");
380    target
381        .to_socket_addrs()
382        .map_err(|e| anyhow::anyhow!("could not resolve {target}: {e}"))?
383        .next()
384        .ok_or_else(|| anyhow::anyhow!("no addresses found for {target}"))
385}
386
387/// Control-plane base URL for a DNS/IP host (optional `:port` is ignored for API;
388/// API always uses [`DEFAULT_API_PORT`] unless the host is already an `http(s)://` URL).
389pub fn api_base_url_for_host(host: &str) -> anyhow::Result<String> {
390    let host = normalize_host_input(host)?;
391    if host.starts_with("http://") || host.starts_with("https://") {
392        return Ok(host.trim_end_matches('/').to_string());
393    }
394    let (host, _) = split_host_port(&host, DEFAULT_API_PORT);
395    Ok(format!("http://{host}:{DEFAULT_API_PORT}"))
396}
397
398/// Apply a one-shot host for this process only (no `client.json` write).
399///
400/// Sets `FLATLAND_API_URL`, `FLATLAND_GATEWAY_ADDR`, and `FLATLAND_HOST_OVERRIDE`
401/// so auth, asset sync, and gateway connect use the same remote without touching
402/// the machine's saved game host. Also points `FLATLAND_SESSION_PATH` at a
403/// host-scoped session file so the default `session.json` stays for local play.
404pub fn apply_host_override(host: &str) -> anyhow::Result<(SocketAddr, String)> {
405    let normalized = normalize_host_input(host)?;
406    let game = resolve_game_host_addr(&normalized)?;
407    let api = api_base_url_for_host(&normalized)?;
408    let (host_only, _) = split_host_port(&normalized, DEFAULT_GAME_PORT);
409
410    std::env::set_var("FLATLAND_HOST_OVERRIDE", &host_only);
411    std::env::set_var("FLATLAND_API_URL", &api);
412    std::env::set_var("FLATLAND_GATEWAY_ADDR", game.to_string());
413
414    if std::env::var_os("FLATLAND_SESSION_PATH").is_none() {
415        if let Some(base) = dirs::config_dir() {
416            let safe: String = host_only
417                .chars()
418                .map(|c| {
419                    if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
420                        c
421                    } else {
422                        '_'
423                    }
424                })
425                .collect();
426            let path = base.join("flatland").join(format!("session.{safe}.json"));
427            std::env::set_var("FLATLAND_SESSION_PATH", path);
428        }
429    }
430
431    Ok((game, api))
432}
433
434/// True when this process applied (or inherited) a one-shot `--host` override.
435pub fn host_override_active() -> bool {
436    std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
437}
438
439/// Strip scheme/path and reject empty hosts. Allows DNS names and `host:port`.
440pub fn normalize_host_input(raw: &str) -> anyhow::Result<String> {
441    let mut host = raw.trim().to_string();
442    if host.is_empty() {
443        anyhow::bail!("game host cannot be empty");
444    }
445    if let Some(rest) = host.strip_prefix("https://") {
446        host = rest.to_string();
447    } else if let Some(rest) = host.strip_prefix("http://") {
448        host = rest.to_string();
449    }
450    if let Some((h, _)) = host.split_once('/') {
451        host = h.to_string();
452    }
453    host = host.trim_end_matches('/').to_string();
454    if host.is_empty() {
455        anyhow::bail!("game host cannot be empty");
456    }
457    Ok(host)
458}
459
460fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
461    // IPv6 in brackets: [2001:db8::1]:7373
462    if let Some(rest) = host.strip_prefix('[') {
463        if let Some((addr, port_part)) = rest.split_once("]:") {
464            if let Ok(p) = port_part.parse::<u16>() {
465                return (format!("[{addr}]"), p);
466            }
467        }
468        return (host.to_string(), default_port);
469    }
470    // host:port — only split on the last colon when the port is numeric
471    if let Some((h, p)) = host.rsplit_once(':') {
472        if !h.is_empty() && p.parse::<u16>().is_ok() && !h.contains(':') {
473            return (h.to_string(), p.parse().unwrap_or(default_port));
474        }
475    }
476    (host.to_string(), default_port)
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn default_is_localhost() {
485        let cfg = ClientConfig::default();
486        assert_eq!(cfg.game_server_addr(), "127.0.0.1:7373".parse().unwrap());
487        assert_eq!(cfg.api_base_url(), "http://127.0.0.1:7380");
488    }
489
490    #[test]
491    fn hud_view_roundtrips_in_config() {
492        let mut cfg = ClientConfig::default();
493        cfg.hud_view = Some("map".into());
494        let json = serde_json::to_string(&cfg).unwrap();
495        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
496        assert_eq!(loaded.hud_view.as_deref(), Some("map"));
497    }
498
499    #[test]
500    fn hud_and_workers_prefs_roundtrip() {
501        let mut cfg = ClientConfig::default();
502        cfg.hud_log_hidden = Some(true);
503        cfg.workers_menu_compact = Some(true);
504        cfg.worker_route_panel_collapsed = Some(true);
505        let json = serde_json::to_string(&cfg).unwrap();
506        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
507        assert_eq!(loaded.hud_log_hidden, Some(true));
508        assert_eq!(loaded.workers_menu_compact, Some(true));
509        assert_eq!(loaded.worker_route_panel_collapsed, Some(true));
510    }
511
512    #[test]
513    fn floating_chat_prefs_roundtrip() {
514        let mut cfg = ClientConfig::default();
515        cfg.floating_chat = FloatingChatPrefs {
516            x: Some(42.5),
517            y: Some(100.0),
518            collapsed: Some(true),
519        };
520        let json = serde_json::to_string(&cfg).unwrap();
521        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
522        assert_eq!(loaded.floating_chat.x, Some(42.5));
523        assert_eq!(loaded.floating_chat.y, Some(100.0));
524        assert_eq!(loaded.floating_chat.collapsed, Some(true));
525    }
526
527    #[test]
528    fn floating_panels_prefs_roundtrip() {
529        let mut cfg = ClientConfig::default();
530        cfg.floating_panels.log = FloatingPanelPrefs {
531            x: Some(10.0),
532            y: Some(20.0),
533            collapsed: Some(false),
534            hidden: Some(true),
535        };
536        let json = serde_json::to_string(&cfg).unwrap();
537        let loaded: ClientConfig = serde_json::from_str(&json).unwrap();
538        assert_eq!(loaded.floating_panels.log.hidden, Some(true));
539        assert_eq!(loaded.floating_panels.log.x, Some(10.0));
540    }
541
542    #[test]
543    fn migrates_legacy_floating_chat_and_log_hidden() {
544        let mut cfg = ClientConfig::default();
545        cfg.floating_chat = FloatingChatPrefs {
546            x: Some(1.0),
547            y: Some(2.0),
548            collapsed: Some(true),
549        };
550        cfg.hud_log_hidden = Some(true);
551        cfg.migrate_floating_panels();
552        assert_eq!(cfg.floating_panels.chat.x, Some(1.0));
553        assert_eq!(cfg.floating_panels.chat.collapsed, Some(true));
554        assert_eq!(cfg.floating_panels.log.hidden, Some(true));
555    }
556
557    #[test]
558    fn split_host_port_handles_dns_and_explicit_port() {
559        assert_eq!(
560            split_host_port("server1.flatland3.com", 7373),
561            ("server1.flatland3.com".into(), 7373)
562        );
563        assert_eq!(
564            split_host_port("server1.flatland3.com:7374", 7373),
565            ("server1.flatland3.com".into(), 7374)
566        );
567        assert_eq!(
568            split_host_port("127.0.0.1", 7373),
569            ("127.0.0.1".into(), 7373)
570        );
571    }
572
573    #[test]
574    fn normalize_strips_scheme() {
575        assert_eq!(
576            normalize_host_input("https://server1.flatland3.com/").unwrap(),
577            "server1.flatland3.com"
578        );
579    }
580
581    #[test]
582    fn api_base_uses_dns_host() {
583        let mut cfg = ClientConfig::default();
584        cfg.game_host = Some("server1.flatland3.com".into());
585        assert_eq!(cfg.api_base_url(), "http://server1.flatland3.com:7380");
586    }
587
588    #[test]
589    fn api_base_url_for_host_defaults_port() {
590        assert_eq!(
591            api_base_url_for_host("server2.flatland3.com").unwrap(),
592            "http://server2.flatland3.com:7380"
593        );
594        assert_eq!(
595            api_base_url_for_host("server2.flatland3.com:7373").unwrap(),
596            "http://server2.flatland3.com:7380"
597        );
598    }
599}