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