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()
63 && self.y.is_none()
64 && self.collapsed.is_none()
65 && self.hidden.is_none()
66 }
67}
68
69#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub game_host: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub api_port: Option<u16>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub game_port: Option<u16>,
165 #[serde(default, skip_serializing_if = "GfxWindowPrefs::is_empty")]
167 pub gfx_window: GfxWindowPrefs,
168 #[serde(default, skip_serializing_if = "FloatingChatPrefs::is_empty")]
170 pub floating_chat: FloatingChatPrefs,
171 #[serde(default, skip_serializing_if = "FloatingPanelsPrefs::is_empty")]
173 pub floating_panels: FloatingPanelsPrefs,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub hud_view: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub hud_log_hidden: Option<bool>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub workers_menu_compact: Option<bool>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub worker_route_panel_collapsed: Option<bool>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub check_updates: Option<bool>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub update_dismissed_version: Option<String>,
192 #[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 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 pub fn save_floating_chat(&mut self, prefs: FloatingChatPrefs) -> anyhow::Result<()> {
251 self.floating_chat = prefs;
252 self.save()
253 }
254
255 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 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 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 pub fn display_game_host(&self) -> String {
320 self.game_host.clone().unwrap_or_else(|| "127.0.0.1".into())
321 }
322
323 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 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
375pub 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
390pub 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
401pub 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
437pub fn host_override_active() -> bool {
439 std::env::var_os("FLATLAND_HOST_OVERRIDE").is_some()
440}
441
442pub 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 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 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}