1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub width: Option<u32>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub height: Option<u32>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub x: Option<u32>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub y: Option<u32>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
29pub struct FloatingChatPrefs {
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub x: Option<f32>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub y: Option<f32>,
36 #[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, 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub game_host: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub api_port: Option<u16>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub game_port: Option<u16>,
162 #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
164 pub gfx_window: GfxWindowPrefs,
165 #[serde(default, skip_serializing_if = "FloatingChatPrefs::is_empty")]
167 pub floating_chat: FloatingChatPrefs,
168 #[serde(default, skip_serializing_if = "FloatingPanelsPrefs::is_empty")]
170 pub floating_panels: FloatingPanelsPrefs,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub hud_view: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub hud_log_hidden: Option<bool>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub workers_menu_compact: Option<bool>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub worker_route_panel_collapsed: Option<bool>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub check_updates: Option<bool>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub update_dismissed_version: Option<String>,
189 #[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 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 pub fn save_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
248 self.floating_chat = prefs;
249 self.save()
250 }
251
252 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 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 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 pub fn display_game_host(&self) -> String {
317 self.game_host.clone().unwrap_or_else(|| "127.0.0.1".into())
318 }
319
320 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 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
372pub 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
387pub 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
398pub 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
434pub fn host_override_active() -> bool {
436 std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
437}
438
439pub 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 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 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}